开屏动画

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

技术 0 评 4 度

起因

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

打开一篇不错的文章 → 拉到评论区 → 手动输入昵称 → 手动输入邮箱 → 手动输入网址 → 结果发现这篇文章是 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
快来做第一个评论的人吧~
△