开屏动画

我又写了个油猴脚本:一键填充博客留言信息

技术 17 评 1355 度

起因

作为一个喜欢到处逛博客、在别人文章下面留言的人,我每隔几天就会遇到同一个烦人场景:

打开一篇不错的文章 → 拉到评论区 → 手动输入昵称 → 手动输入邮箱 → 手动输入网址 → 结果发现这篇文章是 Twikoo、那篇是 Artitalk、另一篇又是 WordPress 原生评论……输入框长得不一样,但我要填的东西一模一样。

更要命的是,很多博客为了防止刷评论,不会自动保存你的信息。你今天填了,明天换个浏览器或者清一下缓存,又得重新填一遍。

填一次两次还行,填个几十次就真的想砸键盘了。

所以我又写了个脚本

成品长这样:页面右下角会出现一个紫色的悬浮按钮「📝 填充」,点一下,评论区的昵称、邮箱、网址就全自动填好了。

image.png

使用流程极其简单:

  1. 点油猴菜单里的「⚙️ 设置留言信息」(或者右键悬浮按钮)
  2. 填一次你的昵称、邮箱、网址、地址
  3. 保存
  4. 以后在任何博客的评论区,只要点一下按钮就行

信息是用 GM_setValue 存在油猴里的,跨站点、跨标签页、跨会话,只要油猴在,你的配置就在。

技术上的几个小坑

写这个脚本看着简单,其实踩了几个坑,记录一下给后来人参考。

坑 1:评论区是懒加载的

很多博客(尤其是 Twikoo、Artitalk 这种基于 Vue 的评论系统)的评论框不是页面加载时就渲染出来的,而是用户滚动到评论区附近或者点击评论框时才动态渲染。

所以脚本不能一上来就去找输入框,得先等页面稳定下来。我现在的做法是页面加载完延迟 1 秒再注入按钮,然后每次点击时实时查询输入框,而不是缓存 DOM 引用。

坑 2:直接改 input.value 没用

这是最坑的一个。对于 Vue / React 这类框架,input.value = 'xxx' 只是改了 DOM 节点的属性,框架内部的响应式数据不会更新。结果就是:输入框看起来填上了,但你一点"发送"按钮,提交的还是空值。

正确做法是模拟真实的输入过程:

// 用原生 setter 赋值(绕过框架对 value 的劫持)
const setter = Object.getOwnPropertyDescriptor(
    HTMLInputElement.prototype, 'value'
).set;
setter.call(input, value);

// 触发框架监听的事件
input.dispatchEvent(new InputEvent('input', { bubbles: true, data: value }));
input.dispatchEvent(new Event('change', { bubbles: true }));

尤其是 InputEvent(比普通的 Event 多带 data 和 inputType),Vue 的 v-model 很多情况下只认它。

坑 3:蜜罐字段

有些博客的表单里藏了一个 input[name="url2"] 这种字段,隐藏起来不让真人看到,专门用来钓机器人的——只要这个字段被填了,就判定是爬虫,评论直接被拒。

所以脚本必须跳过这类字段。我目前是通过名字黑名单 + tabindex="-1" && aria-hidden="true" 的组合来识别。

坑 4:不同评论系统的字段名不一样

这是我遇到的最头疼的问题:

评论系统昵称邮箱网址
Twikoonickmaillink
WordPress 原生authoremailurl
你博客那个自定义表单nameemailwebsite
Artitalknicknameemaillink

所以我的策略是给每个字段配一组选择器,按优先级从上到下试,找到第一个可见且不是蜜罐的就填:

nickname: [
    '#twikoo input[name="nick"]',
    'input[name="nick"]',
    'input[name="nickname"]',
    'form.cmt-form input[name="name"]',
    'input[name="author"]',
    // ...
]

目前这套配置覆盖了 Twikoo、WordPress、Typecho、Hexo 常见主题,以及我遇到的几个自定义表单。如果哪天遇到不在列表里的,加一行就行。

关于样式

现在的 UI 就是下面这个样子,紫色渐变,圆角,能拖拽,会实时反馈状态:

  • 📝 待机
  • ✅ 已填充:昵称、邮箱
  • ⚠️ 请先填写配置
  • ❌ 未找到输入框

说实话这个配色和交互是我自己随便糊的,能用但不一定好看。

如果你不喜欢这个样式,完全可以把脚本丢给 AI(比如 ChatGPT、Claude、DeepSeek 都行),告诉它"帮我把这个按钮改成 XXX 风格",让它帮你改 CSS。 脚本里所有样式都集中在 GM_addStyle 那一大段,改起来没什么技术门槛。

你也可以让它把按钮换成图标、改成黑色、去掉渐变、变小一点、挪到左下角……只要你能描述出想要的样子,AI 基本都能给你改出来。

完整代码

直接复制到油猴里新建脚本就能用:

// ==UserScript==
// @name         博客留言自动填充
// @namespace    http://tampermonkey.net/
// @version      2.2
// @description  在博客留言区自动填充昵称、邮箱、网址、地址
// @author       xiangmingya!
// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// ==/UserScript==

(function() {
    'use strict';

    // ============ 配置 ============
    const CONFIG_KEY = 'blogAutofillConfigV2';

    const defaultConfig = {
        nickname: '',
        email: '',
        website: '',
        address: ''
    };

    // 每个字段一组选择器,按优先级从上到下尝试
    const SELECTORS = {
        nickname: [
            '#twikoo input[name="nick"]',
            '.tk-meta-input input[name="nick"]',
            'input[name="nick"]',
            'input[name="nickname"]',
            'form.cmt-form input[name="name"]',
            'input[name="author"]',
            'input[name="name"]',
            'input[name="username"]',
            'input[id="author"]',
            'input[id="nickname"]',
            'input[id="name"]',
            'input[autocomplete="nickname"]',
            'input[placeholder*="昵称"]',
            'input[placeholder*="姓名"]',
            'input[placeholder*="名字"]'
        ],
        email: [
            '#twikoo input[name="mail"]',
            '.tk-meta-input input[name="mail"]',
            'input[name="mail"]',
            'form.cmt-form input[name="email"]',
            'input[name="email"]',
            'input[type="email"]',
            'input[id="email"]',
            'input[id="mail"]',
            'input[autocomplete="email"]',
            'input[placeholder*="邮箱"]',
            'input[placeholder*="邮件"]'
        ],
        website: [
            '#twikoo input[name="link"]',
            '.tk-meta-input input[name="link"]',
            'input[name="link"]',
            'form.cmt-form input[name="website"]',
            'input[name="url"]',
            'input[name="website"]',
            'input[name="site"]',
            'input[id="url"]',
            'input[id="website"]',
            'input[autocomplete="url"]',
            'input[placeholder*="网址"]',
            'input[placeholder*="网站"]'
        ],
        address: [
            'input[name="address"]',
            'input[id="address"]',
            'input[placeholder*="地址"]'
        ]
    };

    const HONEYPOT_NAMES = ['url2', 'hp', 'honeypot', 'website_url2', '_gotcha'];

    // ============ 配置读写 ============
    function loadConfig() {
        try {
            const raw = GM_getValue(CONFIG_KEY, '');
            if (raw) return Object.assign({}, defaultConfig, JSON.parse(raw));
        } catch (e) {}
        return Object.assign({}, defaultConfig);
    }

    function saveConfig(cfg) {
        GM_setValue(CONFIG_KEY, JSON.stringify(cfg));
    }

    // ============ DOM 查找 ============
    function isHoneypot(el) {
        const name = (el.getAttribute('name') || '').toLowerCase();
        if (HONEYPOT_NAMES.includes(name)) return true;
        if (el.getAttribute('tabindex') === '-1' && el.getAttribute('aria-hidden') === 'true') return true;
        if (el.classList && el.classList.contains('cmt-hp')) return true;
        return false;
    }

    function isVisible(el) {
        if (el.getClientRects().length === 0) return false;
        const style = window.getComputedStyle(el);
        if (style.display === 'none' || style.visibility === 'hidden') return false;
        return true;
    }

    function findInput(selectors) {
        for (const sel of selectors) {
            let list;
            try { list = document.querySelectorAll(sel); } catch (e) { continue; }
            for (const el of list) {
                if (el.disabled || el.readOnly) continue;
                if (isHoneypot(el)) continue;
                if (!isVisible(el)) continue;
                return el;
            }
        }
        return null;
    }

    // ============ 输入模拟(兼容 Vue/Element UI)============
    function simulateInput(el, value) {
        el.focus();
        el.click();

        const proto = el.tagName === 'TEXTAREA'
            ? window.HTMLTextAreaElement.prototype
            : window.HTMLInputElement.prototype;
        const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;

        setter.call(el, '');
        el.dispatchEvent(new Event('input', { bubbles: true }));

        setter.call(el, value);

        // 完整事件链
        try {
            el.dispatchEvent(new InputEvent('input', {
                bubbles: true, cancelable: true, data: value, inputType: 'insertText'
            }));
        } catch (e) {
            el.dispatchEvent(new Event('input', { bubbles: true }));
        }
        el.dispatchEvent(new Event('change', { bubbles: true }));

        try {
            el.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: value }));
        } catch (e) {}

        el.dispatchEvent(new Event('input', { bubbles: true }));

        // 触发 Vue 更新(如可用)
        if (el._vModifiers || el.__vue__) {
            try {
                const evt = document.createEvent('HTMLEvents');
                evt.initEvent('input', true, true);
                el.dispatchEvent(evt);
            } catch (e) {}
        }

        el.dispatchEvent(new Event('blur', { bubbles: true }));
    }

    // ============ 填充主逻辑 ============
    function fillForm() {
        const cfg = loadConfig();
        const details = [];
        const skipped = [];

        const fields = [
            { key: 'nickname', val: cfg.nickname, label: '昵称' },
            { key: 'email',    val: cfg.email,    label: '邮箱' },
            { key: 'website',  val: cfg.website,  label: '网址' },
            { key: 'address',  val: cfg.address,  label: '地址' }
        ];

        // 检查是否有任何已配置的值
        const hasAnyValue = fields.some(f => f.val);

        console.log('=== 开始填充 ===');
        if (!hasAnyValue) {
            console.warn('⚠️ 没有填写任何配置信息');
            return { count: 0, details, skipped, noConfig: true };
        }

        for (const f of fields) {
            if (!f.val) { continue; }
            const el = findInput(SELECTORS[f.key]);
            if (el) {
                simulateInput(el, f.val);
                details.push(f.label);
                console.log(`✅ ${f.label} →`, el, '当前值:', el.value);
            } else {
                skipped.push(f.label);
                console.log(`❌ ${f.label} 未找到`);
            }
        }
        console.log('=== 填充结束,成功:', details, '跳过:', skipped, '===');

        return { count: details.length, details, skipped, noConfig: false };
    }

    // ============ 调试 ============
    function debugDump() {
        console.log('=== 自动填充调试信息 ===');
        document.querySelectorAll('input, textarea').forEach((el, i) => {
            console.log(`[${i}]`, {
                tag: el.tagName, type: el.type,
                name: el.name, id: el.id, placeholder: el.placeholder,
                disabled: el.disabled, readOnly: el.readOnly,
                visible: isVisible(el),
                honeypot: isHoneypot(el),
                value: el.value
            });
        });
        console.log('=== 调试结束 ===');
    }

    GM_registerMenuCommand('🔍 调试:打印所有输入框', debugDump);

    // ============ 悬浮按钮 ============
    function createFloatingButton() {
        if (document.getElementById('blog-autofill-btn')) return;

        const btn = document.createElement('div');
        btn.id = 'blog-autofill-btn';
        btn.textContent = '📝 填充';
        btn.title = '点击自动填充(右键设置)';

        GM_addStyle(`
            #blog-autofill-btn {
                position: fixed; right: 20px; bottom: 100px; z-index: 999999;
                background: linear-gradient(135deg, #667eea, #764ba2);
                color: #fff; padding: 10px 16px; border-radius: 24px;
                cursor: pointer; font-size: 14px; font-weight: 700;
                box-shadow: 0 4px 15px rgba(102,126,234,.4);
                transition: all .3s; user-select: none;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            }
            #blog-autofill-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(102,126,234,.6); }
            #blog-autofill-btn.success { background: linear-gradient(135deg, #11998e, #38ef7d); }
            #blog-autofill-btn.error { background: linear-gradient(135deg, #eb3349, #f45c43); }
            #blog-autofill-overlay {
                position: fixed; inset: 0; background: rgba(0,0,0,.5);
                z-index: 999999; display: none;
            }
            #blog-autofill-overlay.show { display: block; }
            #blog-autofill-panel {
                position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
                z-index: 1000000; background: #fff; border-radius: 12px;
                box-shadow: 0 20px 60px rgba(0,0,0,.3); padding: 24px;
                width: 90%; max-width: 460px; display: none; color: #333;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            }
            #blog-autofill-panel.show { display: block; }
            .baf-title { font-size: 18px; font-weight: 700; margin-bottom: 20px; }
            .baf-field { margin-bottom: 14px; }
            .baf-field label { display: block; font-size: 13px; color: #666; margin-bottom: 6px; }
            .baf-field input {
                width: 100%; padding: 10px 12px; border: 1px solid #ddd;
                border-radius: 8px; font-size: 14px; box-sizing: border-box;
                outline: none; font-family: inherit;
            }
            .baf-field input:focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,.15); }
            .baf-buttons { display: flex; gap: 10px; margin-top: 20px; }
            .baf-buttons button {
                flex: 1; padding: 10px; border: none; border-radius: 8px;
                font-size: 14px; font-weight: 600; cursor: pointer;
            }
            .baf-save { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; }
            .baf-cancel { background: #f0f0f0; color: #666; }
            .baf-tip { font-size: 12px; color: #999; margin-top: 12px; line-height: 1.5; }
        `);

        document.body.appendChild(btn);

        let isDragging = false;

        btn.addEventListener('click', function() {
            if (isDragging) { isDragging = false; return; }
            const r = fillForm();

            // 情况1:没配置任何信息 → 自动打开设置面板
            if (r.noConfig) {
                btn.classList.add('error');
                btn.textContent = '⚠️ 请先填写配置';
                setTimeout(() => {
                    btn.classList.remove('error');
                    btn.textContent = '📝 填充';
                    openPanel();
                }, 600);
                return;
            }

            // 情况2:有配置且填充成功
            if (r.count > 0) {
                btn.classList.add('success');
                let msg = `✅ 已填充:${r.details.join('、')}`;
                if (r.skipped.length > 0) {
                    msg += `(跳过 ${r.skipped.join('、')})`;
                }
                btn.textContent = msg;
                setTimeout(() => {
                    btn.classList.remove('success');
                    btn.textContent = '📝 填充';
                }, 2500);
                return;
            }

            // 情况3:有配置但页面没找到输入框
            btn.classList.add('error');
            btn.textContent = '❌ 未找到输入框(F12 看日志)';
            setTimeout(() => {
                btn.classList.remove('error');
                btn.textContent = '📝 填充';
            }, 2500);
        });

        btn.addEventListener('contextmenu', e => { e.preventDefault(); openPanel(); });

        let sx, sy, sl, st;
        btn.addEventListener('mousedown', e => {
            if (e.button !== 0) return;
            isDragging = false;
            sx = e.clientX; sy = e.clientY;
            const r = btn.getBoundingClientRect();
            sl = r.left; st = r.top;
            const move = e2 => {
                const dx = e2.clientX - sx, dy = e2.clientY - sy;
                if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
                    isDragging = true;
                    btn.style.right = 'auto'; btn.style.bottom = 'auto';
                    btn.style.left = (sl + dx) + 'px';
                    btn.style.top = (st + dy) + 'px';
                }
            };
            const up = () => {
                document.removeEventListener('mousemove', move);
                document.removeEventListener('mouseup', up);
            };
            document.addEventListener('mousemove', move);
            document.addEventListener('mouseup', up);
        });
    }

    // ============ 设置面板 ============
    function openPanel() {
        const cfg = loadConfig();
        let ov = document.getElementById('blog-autofill-overlay');
        let pn = document.getElementById('blog-autofill-panel');
        if (!ov) { ov = document.createElement('div'); ov.id = 'blog-autofill-overlay'; document.body.appendChild(ov); }
        if (!pn) { pn = document.createElement('div'); pn.id = 'blog-autofill-panel'; document.body.appendChild(pn); }

        pn.innerHTML = `
            <div class="baf-title">⚙️ 留言信息设置</div>
            <div class="baf-field"><label>昵称</label>
                <input type="text" id="baf-nickname" value="${esc(cfg.nickname)}" placeholder="你的昵称"></div>
            <div class="baf-field"><label>邮箱</label>
                <input type="email" id="baf-email" value="${esc(cfg.email)}" placeholder="你的邮箱"></div>
            <div class="baf-field"><label>网址(可选)</label>
                <input type="url" id="baf-website" value="${esc(cfg.website)}" placeholder="https://example.com"></div>
            <div class="baf-field"><label>地址(可选)</label>
                <input type="text" id="baf-address" value="${esc(cfg.address)}" placeholder="你的地址"></div>
            <div class="baf-buttons">
                <button class="baf-cancel" id="baf-cancel">取消</button>
                <button class="baf-save" id="baf-save">保存</button>
            </div>
            <div class="baf-tip">💡 保存后点击右下角"填充"按钮即可。右键按钮可再次打开设置。</div>
        `;

        ov.classList.add('show');
        pn.classList.add('show');

        // 空字段标红提示
        setTimeout(() => {
            ['baf-nickname', 'baf-email'].forEach(id => {
                const el = document.getElementById(id);
                if (el && !el.value.trim()) {
                    el.style.borderColor = '#eb3349';
                    el.placeholder = '⚠️ 建议填写';
                }
            });
        }, 50);

        const close = () => { ov.classList.remove('show'); pn.classList.remove('show'); };
        ov.onclick = close;
        document.getElementById('baf-cancel').onclick = close;
        document.getElementById('baf-save').onclick = function() {
            const c = loadConfig();
            c.nickname = document.getElementById('baf-nickname').value.trim();
            c.email = document.getElementById('baf-email').value.trim();
            c.website = document.getElementById('baf-website').value.trim();
            c.address = document.getElementById('baf-address').value.trim();
            saveConfig(c);
            this.textContent = '✅ 已保存';
            setTimeout(() => { this.textContent = '保存'; close(); }, 800);
        };
    }

    function esc(s) {
        if (!s) return '';
        return s.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
    }

    GM_registerMenuCommand('⚙️ 设置留言信息', openPanel);

    // ============ 启动 ============
    function init() {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', () => setTimeout(createFloatingButton, 1000));
        } else {
            setTimeout(createFloatingButton, 1000);
        }
    }
    init();

})();
💡 首次使用记得先在设置里填一次自己的信息,否则点填充会提示你先配置。

一点碎碎念

写这种小工具最大的乐趣在于:解决的是自己每天都会遇到的小麻烦,但解决之后每次用都会爽一下。

我知道网上已经有类似的脚本了(比如「自动填充评论」之类的),但很多都只支持特定的评论系统,或者要改一堆配置。我这个的思路是通用优先——用一组兜底选择器尽量覆盖大多数博客,同时把蜜罐、框架兼容这些细节处理好。

如果你也在到处逛博客、到处留言,希望这个脚本能帮你省下每次输入昵称邮箱的那几十秒。积少成多,一年下来也是好几个小时呢(认真脸)。


反馈:有问题欢迎在评论区留言,或者直接丢给 AI 帮你改

对了,如果哪天你发现某个博客的评论区填不进去,可以按 F12 看控制台,脚本会打印它尝试过哪些选择器。把日志发给我,我加一行配置就行。

博客留言自动填充更新了
广告图片
AD

17 评论
    Sheep5 Safari 27 iPhone 山东济南
    10月16日回复

    不错不错,在手机版Safari上蛮好用的

      湘铭呀! Chrome 155 Windows 10 安徽蚌埠
      10月16日回复
    冰剑 Firefox Browser 157 Windows 10 湖北宜昌
    10月13日回复

    页面漂浮太多东西不习惯,还是小书签的方式更适合。

    浮生一梦 Safari 0 iPad 广东深圳
    10月11日回复

    观摩学习|´・ω・)ノ

    秋风于渭水 Chrome 155 ubuntu 广东东莞
    10月10日回复

    嘿嘿,我也写了填充油猴脚本,不过我的思路不是匹配字段,因为字段的写法实在太多样了,很多人还会故意误导,搞得总是反复按F12看节点名,去脚本里填新名字。所以现在改成,优先模糊匹配页面上可见的文字,然后在关键字节点附近找输入框的节点,再模糊匹配节点名,去找最可能的可见输入框,目前有小半年没更新过了,还没有遇到过填不进去的博客。

      小赵 Chrome 155 Windows 10 广东广州
      10月10日回复

      @秋风于渭水 很好用( ๑´•ω•) "(ㆆᴗㆆ)

      湘铭呀! Chrome 154 Windows 10 安徽蚌埠
      10月10日回复

      @秋风于渭水 哇哦哇哦!立马更新。

    老刘博客 Chrome 155 Linux 河南郑州
    10月10日回复

    惭愧,惭愧,我还没有用过油猴,总是听说这个东西,感觉用起来很麻烦

      湘铭呀! Chrome 154 Windows 10 安徽蚌埠
      10月10日回复

      @老刘博客 可是试试巨好用!哈哈哈

    南香香 Safari 18 iPhone 陕西宝鸡
    10月10日回复

    之前我也做过这个,但是最后发现越来越多的不适配了OωO

      湘铭呀! Chrome 154 Windows 10 安徽蚌埠
      10月10日回复

      @南香香 慢慢适配我看后期我上传到上面定期维护一下

    fengc's Blog Chrome 155 Android Q 澳大利亚
    10月10日回复

    解决痛点的油猴脚本。

      湘铭呀! Chrome 154 Windows 10 安徽蚌埠
      10月10日回复

      @fengc's Blog 是的

    obaby Chrome 152 OSX 山东青岛
    10月10日回复

    这个不同站点需要重复填信息的的确有些烦~~
    更奇怪的是,有的站点的昵称和邮箱的表单映射关系是反的,导致浏览器自动填充工具会填错,不知道是不是故意的。

      湘铭呀! Chrome 154 Windows 10 安徽蚌埠
      10月10日回复

      @obaby 就是的就是的,每次填都浪费时间

搜索文章

输入关键词,找一篇旧文章。