Userscript Development
Write, structure, and manage Tampermonkey/Greasemonkey userscripts in a GitHub repo with auto-update support.
1. Userscript Metadata Template
Every script needs a complete ==UserScript== header:
// ==UserScript==
// @name 🔧 Script Name
// @namespace https://github.com/OWNER/REPO
// @version 1.0.0
// @description Brief description
// @author username
// @match *://*/*
// @noframes
// @icon https://raw.githubusercontent.com/OWNER/REPO/main/assets/icon-128.png
// @grant GM_setClipboard
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @run-at document-idle
// @updateURL https://raw.githubusercontent.com/OWNER/REPO/main/scripts/name.user.js // REMOVE for personal scripts — see pitfall in Section 2
// @downloadURL https://raw.githubusercontent.com/OWNER/REPO/main/scripts/name.user.js // REMOVE for personal scripts — see pitfall in Section 2
// @license MIT
// ==/UserScript==
Key metadata fields
| Field | Purpose |
|---|---|
@icon |
Script icon shown in Tampermonkey dashboard. Use a 128x128 PNG hosted in the repo (assets/icon-128.png) |
@match |
URL patterns to activate on. Use *://*/* for all sites. Specific: *://*.example.com/* |
@grant |
Permissions. GM_setClipboard, GM_notification, GM_xmlhttpRequest are common. none = sandbox only |
@noframes |
Prevent script injection in iframes. Always add this unless the script intentionally needs to run in iframes. Without it, pages with iframes cause double execution (duplicate GM_registerMenuCommand items, double GM_notification popups). |
@run-at |
When to inject: document-idle (after DOM ready) is safest for most scripts |
@updateURL |
Optional. See pitfall below — REMOVE for personal-use scripts. |
@downloadURL |
Same as updateURL. |
@version |
Semantic version. Must increment on each release for update detection |
Common @grant permissions
GM_setClipboard— copy text to clipboard (works even without user gesture)GM_notification— desktop notificationsGM_xmlhttpRequest— cross-origin HTTP requests (bypass CORS)GM_getValue/GM_setValue— persistent storage per scriptGM_addStyle— inject CSS@grant none— no special permissions, runs in page context
Site Filtering Pattern
When a script should only activate on specific sites but you want it available everywhere (for easy user configuration), use @match *://*/* with code-level filtering:
function shouldRun() {
const c = cfg();
if (!c.enabled) return false;
const list = c.sites.split(',').map(s => s.trim()).filter(Boolean);
if (list.length === 0) return true; // empty = all sites
const h = location.hostname;
return list.some(s => h === s || h.endsWith('.' + s) ||
(s.includes('*') && h.match(new RegExp('^' + s.replace(/\*/g, '.*') + '$'))));
}
if (!shouldRun()) return;
This is better than hardcoding @match patterns because users can configure which sites to activate on via the settings panel.
2. Auto-Update Mechanism
Tampermonkey checks @updateURL periodically (user-configurable interval). The flow:
- User installs script from
@downloadURL - Tampermonkey fetches
@updateURLperiodically - Compares
@versionin the fetched file vs installed version - If higher, prompts user to update
Implementation:
@updateURLand@downloadURLshould point to the sameraw.githubusercontent.comURL- Pattern:
https://raw.githubusercontent.com/{owner}/{repo}/main/scripts/{filename} - Just bump
@versionin the script and push — Tampermonkey handles the rest
⚠️ CRITICAL: Remove @updateURL/@downloadURL for personal-use scripts
When distributing scripts internally (personal use, shared via direct download), remove both @updateURL and @downloadURL.
Why:
- Tampermonkey badge bug — Scripts with
@updateURLpointing to the same URL as@downloadURLcause Tampermonkey to constantly re-check for updates. In some Tampermonkey versions (especially BETA), this causes the extension badge counter to grow indefinitely, even though no actual update occurs. User has seen this repeatedly withshow-password. - Unnecessary network calls — Every check interval fetches the raw file, consuming bandwidth and potentially triggering rate limits.
- Zero benefit — Personal users install scripts manually. If the script changes, the user installs the new version fresh (overwrites the old one).
When to keep them:
- ✅ Publishing to Greasyfork for public distribution
- ✅ Script has auto-update explicitly requested
When to REMOVE them:
- ❌ Personal use scripts (password display, cookie clip, keep-alive, etc.)
- ❌ Scripts shared via direct download links
- ❌ Scripts in a shared repo where each user manages their own version manually
After removing: Also remove the corresponding @icon URL (since it typically points to the same raw GitHub path) — or keep it only if the icon is needed for the Tampermonkey dashboard.
Pitfall: Version must be bumped
If you forget to increment @version, users will never see the update prompt. Always bump version before pushing.
3. DOM Injection Patterns
IIFE wrapper (standard)
(function () {
'use strict';
// ... script code ...
})();
Bypassing React/Vue value tracking with nativeInputValueSetter
Modern frameworks (React, Vue, Angular) intercept input.value assignments via property descriptors. Setting input.value directly may be silently overwritten by the framework's state management.
Use the native HTMLInputElement prototype setter to bypass framework interception:
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
// To set a value that bypasses React/Vue:
nativeSetter.call(input, 'new value');
input.dispatchEvent(new Event('input', { bubbles: true })); // notify framework of change
When to use this:
- Setting password field values for obfuscation/display
- Programmatically filling form fields on React/Vue sites
- Any case where
input.value = xworks on some sites but not others
⚠️ Better alternative for visual-only transforms: If you only need to show a different value without changing what gets submitted, use the overlay mask pattern (see Section "Overlay Mask Pattern" above). It avoids all framework conflicts by never touching input.value.
Why it works: Frameworks override the value property on the input instance, but HTMLInputElement.prototype.value still uses the native setter. Using nativeSetter.call() bypasses the framework's override.
Pitfall: Always dispatch new Event('input', { bubbles: true }) after setting via nativeSetter. Some frameworks listen for input events to update their state; without the event, the framework may revert the value on next render.
Injecting CSS
const style = document.createElement('style');
style.textContent = `.my-class { color: red; }`;
document.head.appendChild(style);
Adding buttons next to existing elements
Recommended: Inline-flex sibling insertion. Insert a <span> after the input as a sibling node. Use display:inline-flex so buttons align naturally with the input without modifying the parent's layout.
function enhanceElement(el) {
if (el.dataset.myScript) return;
// Find wrapper, NOT including <form> (too broad)
// Don't modify its position — that causes layout breaks on some pages
const wrapper = el.closest('div,span,td,li,label,p') || el.parentNode;
if (!wrapper || wrapper.querySelector('.my-class')) return;
const box = document.createElement('span');
box.className = 'my-class';
// inline-flex: buttons sit naturally next to the input, no layout disruption
box.style.cssText = 'display:inline-flex;align-items:center;gap:4px;margin-left:6px;vertical-align:middle';
const btn = document.createElement('button');
btn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
// action
});
box.appendChild(btn);
// Insert as sibling — handles placement without absolute positioning
if (el.nextSibling) el.parentNode.insertBefore(box, el.nextSibling);
else el.parentNode.appendChild(box);
el.dataset.myScript = '1';
}
Why not absolute positioning? Setting wrapper.style.position = 'relative' on arbitrary parent elements breaks layouts on many sites (static frames, forms, grid layouts). Absolute positioning inside the wrapper also conflicts with existing CSS. Sibling insertion with inline-flex is non-invasive.
Adding inline buttons next to <a> links (e.g., magnet preview)
When enhancing existing <a> links (not inputs), use insertAdjacentElement('afterend', btn) to place a button immediately after the link. This keeps the button as a sibling element without modifying the <a> tag or its parent layout.
Pattern: addPreviewBtn with dataset dedup
function addPreviewBtn(a) {
if (a.dataset.mpBtnAdded) return;
a.dataset.mpBtnAdded = '1';
const btn = document.createElement('span');
btn.className = 'mp-preview-btn';
btn.innerHTML = '🧲 预览';
btn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
showPreview(a.href);
});
a.insertAdjacentElement('afterend', btn);
}
Key design decisions:
dataset.mpBtnAddedprevents duplicate buttons on re-scansinsertAdjacentElement('afterend')places the button as a sibling after the<a>, not inside it- Click handler calls
e.preventDefault()+e.stopPropagation()to prevent the default link navigation and event bubbling - Works with both existing
<a href="magnet:...">and dynamically created<a>elements inprocessTextNodes
CSS for inline link-adjacent buttons:
.mp-preview-btn {
display: inline-block;
margin-left: 6px; margin-right: 4px;
padding: 2px 10px;
background: linear-gradient(135deg, #7c3aed, #a78bfa);
color: #fff; border-radius: 12px;
font-size: 12px; font-weight: 600; cursor: pointer;
white-space: nowrap; user-select: none;
vertical-align: middle; line-height: 1.6;
transition: all 0.15s ease;
}
Pitfall: The SKIP set in text-node scanners often includes 'A' — this means magnet links already wrapped in <a> tags (the most common case on torrent sites) are never processed by processTextNodes. Solution: either remove 'A' from SKIP, or handle <a> elements separately via highlightLinks + addPreviewBtn.
Overlay Mask Pattern (preferred over nativeInputValueSetter)
The nativeInputValueSetter approach still has problems: it can conflict with framework state management, and form submissions may still break. The overlay mask pattern avoids all of these by never touching input.value at all.
How it works: Keep input.value as the real password forever. Create a positioned div overlay that visually shows the obfuscated text. The form always submits the real password.
// Overlay mask — input.value is NEVER modified
function applyOverlay(input, obfuscatedText) {
// Remove existing overlay if any
const existing = input.parentElement?.querySelector('.pw-tk-ov');
if (existing) existing.remove();
const ov = document.createElement('div');
ov.className = 'pw-tk-ov';
// Copy input's visual properties
const s = getComputedStyle(input);
ov.style.cssText = `
position:absolute; top:0; left:0; right:0; bottom:0;
display:flex; align-items:center;
font:${s.fontSize} ${s.fontFamily};
padding:${s.padding}; color:${s.color};
pointer-events:none; overflow:hidden; white-space:nowrap;
border:1px solid #ccc; border-radius:${s.borderRadius};
background:#fff;
`;
ov.textContent = obfuscatedText;
// Ensure parent is positioned
if (getComputedStyle(input.parentElement).position === 'static')
input.parentElement.style.position = 'relative';
input.parentElement.appendChild(ov);
}
function removeOverlay(input) {
const ov = input.parentElement?.querySelector('.pw-tk-ov');
if (ov) ov.remove();
}
Why this is better than nativeInputValueSetter:
| Aspect | nativeInputValueSetter |
Overlay Mask |
|---|---|---|
| Framework conflict | May still trigger reactivity | None — value unchanged |
| Form submission | Needs restoreReal() hack |
Always real password |
| Copy button | Needs box._real to track |
Read input.value directly |
| Input events | May trigger side effects | No side effects |
| Password managers | Can confuse autofill logic | No interference |
⚠️ Overlay mask styling fragility: The overlay div must perfectly match the input's visual appearance (font, padding, height, background, dark mode, etc.). In practice, copying getComputedStyle(input) misses edge cases (transparent backgrounds inherited from parents, box-shadow borders, line-height quirks). On some sites the overlay "leaks" — users can see it's a separate layer. This approach works on ~80% of sites but the remaining 20% look broken.
When to use overlay mask: When you need visual transformation AND can accept that ~20% of sites may have minor styling mismatches. Test on your target sites first.
When to consider removing the feature instead: If the visual transformation (e.g., password obfuscation, permanent type change) causes more problems than it solves — broken form submission, styling leaks, user confusion — the right fix is to remove the feature entirely rather than keep patching it. A simpler script that works reliably beats a complex one that works on most sites. This is exactly what happened with alwaysShow in show-password v3.1.0.
When nativeInputValueSetter is still useful: Programmatically filling forms (auto-login, form-fill scripts) where you WANT the framework to know the value changed.
Extended selectors for international sites
Standard selectors (type="password", name*="pass") miss password fields on Chinese/international sites that use different attribute patterns:
document.querySelectorAll([
'input[type="password"]',
'input[name*="pass"]',
'input[name*="pwd"]',
'input[autocomplete="current-password"]',
'input[autocomplete="new-password"]',
// Extended for Chinese sites:
'input[aria-label*="密码"]',
'input[placeholder*="密码"]',
'input[placeholder*="password" i]',
'input[id*="pass"]',
].join(',')).forEach(enhance);
Pitfall: dataset flag for dedup — set it AFTER success, not before
Use el.dataset.myScriptFlag = '1' to mark processed elements. Critical: set it AFTER the element is successfully enhanced, not before. If you set it before validation and the enhancement fails (e.g., no suitable parent found), the element is permanently marked as "done" and will never be retried — even on subsequent MutationObserver scans.
// ❌ BROKEN: marks element before checking if enhancement is possible
function enhance(el) {
if (el.dataset.pwTk) return;
el.dataset.pwTk = '1'; // set too early!
const w = el.closest('div,span,td,li,label,p') || el.parentNode;
if (!w) return; // early return → element permanently skipped
// ... create buttons ...
}
// ✅ CORRECT: set flag only after successful enhancement
function enhance(el) {
if (el.dataset.pwTk) return;
const w = el.closest('div,span,td,li,label,p') || el.parentNode;
if (!w) return; // element NOT marked → will retry next scan
// ... create buttons ...
el.dataset.pwTk = '1'; // set after success
}
This is especially common with password fields on Chinese sites (Baidu, Taobao) where the DOM structure is unusual and closest() may fail to find a suitable parent on the first scan.
4. MutationObserver for Dynamic Content
SPAs load content dynamically. A MutationObserver re-runs your enhancement logic when the DOM changes:
let debounceTimer = null;
const observer = new MutationObserver(() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(scanAndEnhance, 300);
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
Key points
- Always debounce — MutationObserver fires rapidly during DOM mutations. Debounce at 200-300ms.
- Observe
document.documentElementwithsubtree: trueto catch all changes - Scan for elements by multiple selectors — e.g.,
input[type="password"], input[name*="pass"], input[autocomplete="current-password"]covers more sites
5. Clipboard Fallback
GM_setClipboard is ideal but not always available. Use a fallback chain:
function copyToClipboard(text) {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text, 'text');
} else {
navigator.clipboard.writeText(text).catch(() => {
// Final fallback: temporary textarea
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
});
}
}
6. Repository Structure
Per-script folder structure — each script gets its own directory with its own docs and assets:
browser-toolkit/
├── README.md # Hub page: script list + install links
├── show-password/
│ ├── show-password.user.js # Script
│ ├── docs/show-password.md # Greasyfork description
│ └── assets/icon-128.png # Script icon
└── stay-awake/
├── stay-awake.user.js
└── docs/stay-awake.md
Why per-script folders? Each script is an independent project with its own versioning, docs, and assets. When you have 2+ scripts, flat scripts/ becomes messy — assets/docs get mixed, and @updateURL paths become ambiguous.
README should include
- Hub listing all scripts with install links
- One-liner per script with install link to raw GitHub URL
- Development conventions (folder structure, versioning)
File naming
- Script:
{feature-name}/{feature-name}.user.js(matches folder name) - Docs:
{feature-name}/docs/{feature-name}.md - Icons:
{feature-name}/assets/icon-128.png
@updateURL path must match folder structure
// @updateURL https://raw.githubusercontent.com/OWNER/REPO/main/{folder}/{script}.user.js
// @downloadURL https://raw.githubusercontent.com/OWNER/REPO/main/{folder}/{script}.user.js
// @icon https://raw.githubusercontent.com/OWNER/REPO/main/{folder}/assets/icon-128.png
6.1 Shared Dictionary Pattern
When multiple scripts need common data (site blacklists, sensitive site lists, etc.), create a dict/ folder at the repo root. Scripts fetch dictionaries at runtime and cache them.
browser-toolkit/
├── dict/
│ ├── sensitive-sites.txt # Bank/payment/gov domains to skip
│ └── README.md
├── show-password/
└── stay-awake/
Dictionary file format:
- Plain text, one entry per line
- Comments with
# - Wildcards supported:
*.bank.com - Categorized by filename:
sensitive-sites.txt,blocked-sites.txt, etc.
Loading pattern in scripts:
const DICT_URL = 'https://raw.githubusercontent.com/OWNER/REPO/main/dict/sensitive-sites.txt';
let cache = GM_getValue('dict_cache', null);
let cacheTime = GM_getValue('dict_cache_time', 0);
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
async function loadDict() {
if (cache && Date.now() - cacheTime < CACHE_TTL) return cache;
try {
const resp = await fetch(DICT_URL);
const text = await resp.text();
cache = text.split('\n')
.map(l => l.trim())
.filter(l => l && !l.startsWith('#'));
cacheTime = Date.now();
GM_setValue('dict_cache', cache);
GM_setValue('dict_cache_time', cacheTime);
return cache;
} catch (e) {
return cache || []; // offline fallback
}
}
Pause Auto-Save Until First User Interaction
When auto-saving form data, do NOT save on page load. Browser autofill and password managers populate fields after DOM ready — if you save immediately, you'll capture the autofill state and overwrite it later when the user actually fills the form.
Pattern: Track user activity; enable auto-save only after first real interaction:
let userActive = false;
// Listen for real user activity
['mousemove', 'keydown', 'click'].forEach(evt => {
document.addEventListener(evt, () => { userActive = true; }, { passive: true });
});
// Auto-save only after user has interacted
form.addEventListener('input', () => {
if (!userActive) return;
clearTimeout(debounce);
debounce = setTimeout(() => saveForm(form), 2000);
});
// beforeunload also respects the flag
window.addEventListener('beforeunload', () => {
if (userActive) saveForm(form);
});
Re-entry flags need try/finally protection
When using a _busy flag to prevent re-entrant operations (e.g., avoiding infinite loops when modifying an input value triggers its own event listener), wrap the body in try/finally so the flag is always reset:
// ❌ BUG: if nSet.call() throws, _busy stays true forever
let _busy = false;
const reEnc = () => {
if (_busy) return;
_busy = true;
nSet.call(input, enc(input.value, ...)); // if this throws...
_busy = false; // ...this never runs
};
// ✅ CORRECT: try/finally ensures cleanup
let _busy = false;
const reEnc = () => {
if (_busy) return;
_busy = true;
try {
nSet.call(input, enc(input.value, ...));
} finally {
_busy = false; // always resets, even on error
}
};
Encrypt/obfuscation runs before autofill completes
When enhance() runs at document-idle, password fields may still be empty (browser autofill/populate happens after). If you obfuscate the value immediately, you obfuscate an empty string — then autofill overwrites it with the real password, showing plaintext instead of obfuscated text.
Fix: Listen for input and change events to detect when the value is populated, then re-apply obfuscation:
if (c.encrypt) {
const doEnc = () => {
box._real = input.value;
nativeSetter.call(input, enc(input.value, c.letterShift, c.digitShift));
};
doEnc();
// Re-apply when value changes (e.g., autofill)
let _busy = false;
const reEnc = () => {
if (_busy) return; _busy = true;
try {
const cur = input.value;
if (cur && cur !== box._real && cur !== enc(box._real, c.letterShift, c.digitShift)) {
box._real = cur;
nativeSetter.call(input, enc(cur, c.letterShift, c.digitShift));
}
} finally { _busy = false; }
};
input.addEventListener('input', reEnc);
input.addEventListener('change', reEnc);
}
6.2 URL Normalization for Storage Keys
When storing data keyed by URL, normalize to avoid duplicates:
function normalizeUrl(url) {
try {
const u = new URL(url);
u.protocol = 'https:';
u.hash = '';
// Sort query params
const params = [...u.searchParams.entries()].sort();
u.search = params.length ? '?' + params.map(([k,v]) => `${k}=${v}`).join('&') : '';
// Remove trailing slash (except root)
if (u.pathname !== '/' && u.pathname.endsWith('/')) {
u.pathname = u.pathname.slice(0, -1);
}
return u.toString();
} catch { return url; }
}
URL Alias Mapping (for storage key brevity)
Full URLs are long. Map them to short aliases:
function getAlias(url) {
const map = GM_getValue('url_map', {});
const normalized = normalizeUrl(url);
// Check existing
for (const [alias, stored] of Object.entries(map)) {
if (stored === normalized) return alias;
}
// Create new alias
const alias = Math.random().toString(36).slice(2, 6);
map[alias] = normalized;
GM_setValue('url_map', map);
return alias;
}
Pitfall: url_map cleanup
When deleting form data for a domain, also clean up orphaned url_map entries to prevent storage bloat.
6.3 Smart Delay for Form Restore
When restoring form data, detect whether the form has <select> elements to decide if sequential restore with delay is needed:
function needsDelay(form) {
return form.querySelectorAll('select').length > 0;
}
async function restoreFields(fields, delayMs) {
for (const field of fields) {
setFieldValue(field.el, field.value);
if (needsDelay(field.form)) {
await new Promise(r => setTimeout(r, delayMs));
}
}
}
This avoids unnecessary delay on pure-text forms while properly handling cascading selects.
7. Deployment Checklist
- Write script with complete metadata header
- Decide: @updateURL/@downloadURL? See Section 2 — remove for personal-use scripts
- Set initial
@version(1.0.0) - Validate syntax:
node --check script.user.js— catches Python syntax contamination, duplicate declarations, missing brackets - Test in Tampermonkey (install from raw URL)
- Commit and push
- Bump
@versionfor every future update (only if @updateURL is used)
10. Working Example
A complete userscript demonstrating IIFE, MutationObserver, clipboard fallback, toast notifications, dedup, SVG icons, and Shadow DOM settings is available in the skill's references directory.
12. Testing Userscripts via CDP
Two levels of CDP testing exist: stub-based (no Tampermonkey needed) and real Tampermonkey (full integration). Use both.
Level 1: GM_* API Stubs (quick syntax/logic check)
Tampermonkey provides GM_getValue, GM_setValue, etc. as native APIs. For CDP testing, stub them with localStorage-backed equivalents:
// GM_* API stubs for CDP testing
var GM_data = {};
function GM_getValue(k, d) { return k in GM_data ? GM_data[k] : d; }
function GM_setValue(k, v) { GM_data[k] = v; }
function GM_removeValue(k) { delete GM_data[k]; }
function GM_listValues() { return Object.keys(GM_data); }
function GM_setClipboard(t) { try { navigator.clipboard.writeText(t); } catch(e) {} }
function GM_registerMenuCommand(t, fn) { console.log('[GM Menu]', t); }
// For scripts using GM_xmlhttpRequest:
function GM_xmlhttpRequest(d) {
var x = new XMLHttpRequest();
x.open(d.method || 'GET', d.url);
x.onload = function() { if (d.onload) d.onload({status: x.status, responseText: x.responseText}); };
x.onerror = function() { if (d.onerror) d.onerror(); };
x.send();
}
Injection pattern (Python + websockets)
import json, asyncio, websockets, urllib.request
async def inject_userscript(ws_url, script_path):
with open(script_path) as f:
code = f.read()
gm_stub = """/* ... GM stubs from above ... */"""
async with websockets.connect(ws_url) as ws:
# Inject GM stubs first
await ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
"params": {"expression": gm_stub}}))
await ws.recv()
# Inject the userscript
await ws.send(json.dumps({"id": 2, "method": "Runtime.evaluate",
"params": {"expression": code}}))
resp = json.loads(await ws.recv())
if 'exceptionDetails' in resp.get('result', {}):
print("ERROR:", resp['result']['exceptionDetails'])
else:
print("OK")
# Check results
await asyncio.sleep(2)
await ws.send(json.dumps({"id": 3, "method": "Runtime.evaluate",
"params": {"expression": "document.querySelectorAll('.my-class').length"}}))
result = json.loads(await ws.recv())
print("Elements:", result['result']['result']['value'])
Level 2: Install into Real Tampermonkey via CDP (full integration)
When the user has Tampermonkey installed in the host browser and you have CDP access, you can push scripts directly into Tampermonkey for real testing — no manual steps needed.
⚠️ Debugging: Capture Tampermonkey injection errors via Console.enable
When a script installs but doesn't run, use CDP's Console.enable + Page.reload to capture Tampermonkey's error messages:
# Enable console monitoring BEFORE reload
await ws.send(json.dumps({"id": 1, "method": "Console.enable"}))
await ws.recv()
# Reload the page
await ws.send(json.dumps({"id": 2, "method": "Page.reload"}))
# Collect console messages for 5 seconds
messages = []
start = asyncio.get_event_loop().time()
while asyncio.get_event_loop().time() - start < 5:
try:
r = await asyncio.wait_for(ws.recv(), timeout=1)
data = json.loads(r)
if data.get("method") == "Console.messageAdded":
msg = data["params"]["message"]
level = msg.get("level", "log")
text = msg.get("text", "")
if level in ("error", "warning") or "tampermonkey" in text.lower():
messages.append(f"[{level}] {text[:200]}")
elif data.get("method") == "Runtime.exceptionThrown":
err = data["params"]["exceptionDetails"]
messages.append(f"[EXCEPTION] {err.get('text', '')[:200]}")
except asyncio.TimeoutError:
pass
Key signals in Tampermonkey console output:
injected: env: missing script "UUID"!→ Script was edited via CDP but not properly registered in TM storage. Re-install via proper flow.SyntaxError: Unexpected token→ JavaScript syntax error in the script (e.g., Pythonb''). Runnode --checkto find the exact line.- No TM messages at all +
GM_infoisundefined→ Tampermonkey didn't inject any script on this page. Check@matchrules, BETA "Allow User Scripts" setting, or if the page is blocked.
How it works: Tampermonkey intercepts navigation to .user.js files and opens its install confirmation dialog. You can trigger this via CDP, then click "Install" on the dialog.
Step-by-step workflow
-
Start a local HTTP server to serve the
.user.jsfile:# In background — use terminal(background=true) cd /path/to/script-folder python3 -m http.server 18080 --bind 0.0.0.0 -
Get the container's IP visible from the host browser:
hostname -i # e.g., 172.17.0.4⚠️
127.0.0.1inside the container is NOT the same as127.0.0.1on the host. Use the container's network IP. -
Navigate the browser to the script URL via CDP:
# From a regular page tab, navigate to the .user.js file await ws.send(json.dumps({ "id": 1, "method": "Page.navigate", "params": {"url": f"http://{container_ip}:18080/{script_name}.user.js"} })) # Tampermonkey intercepts this and redirects to: # chrome-extension://EXT_ID/ask.html?aid=UUID -
Wait for the install dialog — check tabs via
/json/list:tabs = json.loads(subprocess.check_output(['curl', '-s', 'http://127.0.0.1:9222/json/list'])) for tab in tabs: if 'ask.html' in tab.get('url', '') and 'gcalenpjmijncebpfijmoaglllgpjagf' in tab.get('url', ''): # This is the Tampermonkey confirmation dialog -
Click the Install button on the dialog:
async with websockets.connect(ask_tab['webSocketDebuggerUrl']) as ws: # ⚠️ Install button is <input type="button">, NOT <button> # Text varies: "Install" (first) / "Update" (bump) / "Reinstall" (same ver) await ws.send(json.dumps({ "id": 1, "method": "Runtime.evaluate", "params": {"expression": """ const all = document.querySelectorAll('*'); for (const el of all) { const r = el.getBoundingClientRect(); if (r.width > 0 && r.height > 0 && el.tagName === 'INPUT' && ['Install','Update','Reinstall'].includes(el.value)) { el.click(); break; } } """} })) -
Verify — reload the target page, check Tampermonkey icon shows active script count.
ask.html Dialog Interaction Pitfalls
The Tampermonkey install dialog (ask.html) has several gotchas when interacting via CDP:
| Issue | Detail | Fix |
|---|---|---|
Button is <input>, not <button> |
Dialog uses <input type="button" class="button install">. CSS class is button install, but tag is INPUT. |
Use el.tagName === 'INPUT' and match by .value |
| Button text varies | "Install" / "Update" / "Reinstall" depending on whether script already exists | Check all three values |
.click() may not work |
Some Tampermonkey versions ignore synthetic click events | Fall back to Input.dispatchMouseEvent with x/y coordinates from getBoundingClientRect() |
| Page loads slowly | Dialog shows "Please wait..." spinner before buttons appear | Wait 3+ seconds after navigation before querying buttons |
| Multiple elements match "Install" text | Navigation items also contain "Install" text | Filter by getBoundingClientRect() width > 0 AND tagName === 'INPUT' |
Tampermonkey extension IDs
There are two Tampermonkey variants with different extension IDs:
| Variant | Extension ID | Icon | Notes |
|---|---|---|---|
| Standard | hdokiejnpknakednockmjhgnfkipjodjl |
Black icon | Stable release |
| BETA | gcalenpjmijncebpfijmoaglllgpjagf |
Red icon | Test/beta releases |
- Install dialog always comes from:
chrome-extension://gcalenpjmijncebpfijmoaglllgpjagf/ask.html?aid=UUID
⚠️ BETA "Allow User Scripts" setting: Tampermonkey BETA has a security setting that must be enabled for userscripts to run. If scripts are installed but not executing, check the BETA Tampermonkey settings page for "Allow User Scripts" and enable it. The dashboard may show a warning: "Please enable the Allow User Scripts extension setting."
⚠️ Multiple Tampermonkey installations cause duplicate injection: If both Standard and BETA Tampermonkey are installed in the same Chrome profile, both will inject matching scripts. This causes:
GM_registerMenuCommandregistering duplicate menu items- Clicking one menu command triggers both scripts → double notifications (e.g., "已复制 8 个" + "已复制 4 个")
- Different
document.cookieresults if the script runs in different frames/contexts
Fix: Disable or remove one variant in chrome://extensions. The user should keep only the Tampermonkey version they actually use.
Pitfalls
| Pitfall | Detail |
|---|---|
chrome-extension:// navigation blocked |
Chromium returns ERR_BLOCKED_BY_CLIENT when navigating to extension pages via CDP from regular pages. BUT: Tampermonkey's own dialog (triggered by .user.js navigation) works fine. Don't try to open chrome-extension://hdokiejnpknakednockmjhgnfkipjodjl/options.html directly. |
| Container IP vs localhost | Use hostname -i to get the container's IP visible from the host. 127.0.0.1 from inside the container ≠ host's 127.0.0.1. |
| Tampermonkey APIs invisible to CDP | GM_* functions are injected into the content script world, not the main page world. Runtime.evaluate runs in the main world — so typeof GM_setClipboard always shows undefined. This doesn't mean the script isn't loaded. Use screenshot verification or check the Tampermonkey icon badge. |
| Dialog button selector | The Install button has class button install and may have a dynamic ID. Use document.querySelector('button.install') or scan all <button> elements for one with text "Install". |
| Clean up HTTP server | Kill the background server after testing: find PID via port and kill it. |
What this enables vs. stub testing
| Capability | Stub (Level 1) | Real Tampermonkey (Level 2) |
|---|---|---|
| Syntax check | ✅ | ✅ |
| DOM manipulation | ✅ | ✅ |
| GM_registerMenuCommand | ❌ (console.log only) | ✅ Real menu |
| GM_setClipboard | ❌ (may fail) | ✅ Real clipboard |
| GM_notification | ❌ | ✅ Real notifications |
| GM_getValue/GM_setValue | ⚠️ (stub only) | ✅ Persistent storage |
| Cross-origin requests | ❌ | ✅ (via GM_xmlhttpRequest) |
| Auto-update mechanism | ❌ | ✅ |
Level 3: Real-world testing on target websites
After Level 2 passes, test on actual target websites with Tampermonkey. This catches site-specific issues (framework conflicts, CSP restrictions, etc.).
Recommended testing workflow
- Syntax check (before anything else): Run
node --check script.user.jsto catch syntax errors (b'', duplicateconst, missing brackets) before installing into Tampermonkey. This catches errors that would silently block script injection. - Syntax check: Inject stubs via CDP, confirm no
exceptionDetails - Integration check: Install into Tampermonkey via Level 2, verify menu/clipboard/notifications work
- Real-world check: Test on actual target websites
- Commit: Only after all three levels pass
Limitations (important)
| Issue | Cause | Impact |
|---|---|---|
| Closed shadow DOM invisible | attachShadow({ mode: 'closed' }) returns null for element.shadowRoot from external JS |
Can't query/click elements inside Shadow DOM settings panels via CDP. Use screenshots for visual verification instead |
| Async init fails | Scripts with async function init() that fetch remote resources (dictionaries, configs) may not complete before you check results |
Form-save, scripts with GM_xmlhttpRequest to external URLs |
| MutationObserver timing | Observer is set up on injection, but DOM is already loaded — no mutations fire | Scripts that rely on observer to process existing elements |
addScriptToEvaluateOnNewDocument unavailable |
This CDP method is only on browser-level WebSocket, not page-level | Can't persist scripts across navigations |
| Data URL security context | data: URLs have opaque origin — CORS, fetch, and some APIs behave differently |
Scripts making cross-origin requests fail on data: URLs |
| WebSocket URL changes on navigation | After Page.navigate, the old WebSocket URL may become unresponsive |
Must re-fetch URL from /json/list after navigation |
chrome-extension:// navigation blocked |
Chromium returns ERR_BLOCKED_BY_CLIENT for extension page URLs via CDP. Use Tampermonkey's .user.js interception flow instead |
Can't open Tampermonkey options directly, but Level 2 install flow works |
| Cloudflare-protected sites redirect CDP navigation | Sites behind Cloudflare (e.g., heimacili.org) redirect Page.navigate to the homepage even after CF challenge passes in a real browser. The detail page never loads — location.href shows the homepage, not the target URL. |
Test these sites manually in the user's real browser. CDP cannot bypass CF's bot detection for specific paths, even with a real Chrome instance. |
What works vs. what doesn't (by testing level)
Level 1 (CDP stubs):
- ✅ Synchronous DOM manipulation, event listener setup, simple state checks
- ⚠️ Async initialization, network-dependent features
- ❌ GM_registerMenuCommand, GM_setClipboard, GM_notification, full integration
Level 2 (CDP + real Tampermonkey):
- ✅ Everything — GM_* APIs, menu commands, clipboard, notifications, storage
- ⚠️ Closed shadow DOM still invisible to CDP DOM queries
- ❌ Visual verification still requires screenshots
⚠️ Be honest about what CDP testing covers
When reporting test results to the user, explicitly state what was tested and what wasn't. Don't say "验证通过" if you only tested document.cookie but not the GM_* API calls. The user expects honesty about testing gaps.
Good: "CDP 验证了核心逻辑(document.cookie 能拿到 Cookie),但 GM_setClipboard 和 GM_registerMenuCommand 需要装到 Tampermonkey 里才能真正测试。"
Bad: "✅ 脚本逻辑验证通过" (when only document.cookie was tested)
Recommended workflow
- Syntax check: Inject via CDP with stubs, confirm no
exceptionDetails - Unit check: Navigate to a simple test page, inject, verify DOM changes
- Integration check: Install in Tampermonkey via Level 2 (HTTP server + CDP navigation), verify menu/clipboard/notifications
- Real-world check: Test on actual target websites with Tampermonkey
- Commit: Push to dev, verify, merge to main with bumped @version
User preference: Discuss first, test before commit
The user strongly prefers:
- Discuss approach before coding — "我们先探讨一下" means explain the tradeoffs, propose options, get alignment before writing code. Don't rush to implement.
- Test in browser before committing — when CDP access is available, install the script into the user's real Tampermonkey via Level 2, then verify visually (screenshot). This catches UI issues that code review misses. Use SSH tunnel + CDP for testing in container environments where browsers aren't directly available.
- Don't over-engineer — if a feature causes more problems than it solves (e.g., encryption breaking form submission), the right answer is often to remove it, not patch endlessly. "本来就是一个小功能,先让代码量尽可能的少"
- Tampermonkey is already installed — the user will install Tampermonkey themselves. Don't try to download/install the extension via CDP (Chromium blocks extension page access). Focus on pushing scripts into the already-installed Tampermonkey.
13. Greasyfork Publishing
After the script is working, publish to greasyfork.org for discoverability.
Prerequisites
- Greasyfork account (GitHub login supported)
- Script pushed to GitHub with working
@updateURL/@downloadURL - A
docs/description.mdfile in the repo (see template below)
Submission steps
- Go to greasyfork.org → sign in → "Submit Script"
- Paste the full script code (from raw GitHub URL)
- Fill in:
- Name: script display name
- Description: paste content from
docs/description.md - Code License: MIT
- Additional Info: GitHub repo URL
- Submit → wait for review (usually 1-3 days)
Greasyfork description template (docs/description.md)
# Script Name
One-line description of what it does.
## Features
- Feature 1
- Feature 2
## Installation
1. Install Tampermonkey
2. Click [Install Script](raw-github-url)
3. Confirm installation
## Usage
Brief usage instructions.
## Privacy
- No data collected
- All data stored locally
## Changelog
### vX.Y.Z
- Changes
Pitfall: Search not finding your script
Greasyfork search indexes scripts by name and description. Ensure the script name is descriptive and the description contains relevant keywords. Scripts are NOT indexed immediately after submission — wait for review approval.
15. SVG Icon Preferences (User Style Guide)
User strongly prefers SVG icons over emoji. Emoji (📦📄🎬📝) were called "比较丑" (ugly). Use Lucide-style linear SVG icons with consistent stroke width.
Icon library pattern
Define all SVG icons in a single SVG object at the top of the script:
const SVG = {
copy: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
hd: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>`,
file: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>`,
hash: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>`,
};
Icon sizing conventions
- Header buttons (Copy, Close): 18px SVG in 32px click target, color
#94a3b8, hover blue - Meta row icons (size, file count): 14px SVG, color
#94a3b8 - Source tag icon (hash): 12px SVG inside pill
- File list icons: 16px SVG, color
#94a3b8
Copy button: icon-only SVG next to close
The Copy button should be a small icon-only SVG button, placed next to the close button in the header, NOT a text button and NOT on its own row. Same visual weight as the close button:
.mp-btn-copy {
width: 32px; height: 32px; border: none; background: none;
color: #94a3b8; cursor: pointer; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
transition: all .15s; padding: 0;
}
.mp-btn-copy svg { width: 18px; height: 18px; }
.mp-btn-copy:hover { background: #eff6ff; color: #2563eb; }
Source/via tag: blue pill style
Data source attribution (e.g., "whatslink + itorrents") should be a light blue pill tag with hash icon, not plain gray text:
.mp-sources {
display: inline-flex; align-items: center; gap: 4px;
background: #eff6ff; color: #2563eb; padding: 1px 8px;
border-radius: 10px; font-size: 11px; font-weight: 500;
}
.mp-sources svg { width: 12px; height: 12px; }
No emoji rule
Never use emoji for UI icons (📦📄🎬📝 etc.). Always use SVG. This applies to:
- Meta info row (size, type, file count)
- File type indicators in file list
- Section headings (use CSS styling, not emoji)
- Toast/notification icons
10. Settings / Persistent Config Pattern
For scripts that need user-configurable settings, use GM_getValue/GM_setValue:
const DEFAULTS = { enabled: true, optionX: false };
function getConfig() {
return {
enabled: GM_getValue('my_enabled', DEFAULTS.enabled),
optionX: GM_getValue('my_optionX', DEFAULTS.optionX),
};
}
function saveConfig(cfg) {
GM_setValue('my_enabled', cfg.enabled);
GM_setValue('my_optionX', cfg.optionX);
}
Don't forget to declare @grant GM_getValue and @grant GM_setValue in the metadata header.
⚠️ GM storage persists across reinstalls — code defaults become meaningless
Once GM_setValue('pw_encrypt', true) is called, the saved true persists even if the script is reinstalled or the code default changes to false. Users who previously enabled a feature will see it re-enabled automatically, with no obvious way to reset.
Always provide a "重置默认" (Reset to Defaults) button in the settings panel:
// Define defaults as a constant
const D = { enabled: true, encrypt: false, alwaysShow: false };
// In settings panel, add a reset button
sh.getElementById('s-rst').addEventListener('click', e => {
e.stopPropagation();
// Re-save all defaults to GM storage
for (const [k, v] of Object.entries(D)) GM_setValue('pw_' + k, v);
closeSettings(); toast('✅ 已重置为默认设置');
});
Why this matters: Users often don't understand why their "default" setting isn't actually default. The reset button is the escape hatch. Without it, the only fix is clearing Tampermonkey storage manually (which non-technical users won't do).
Settings UI: Choose based on target audience
There are two patterns for settings access — pick the right one:
Pattern A: GM_registerMenuCommand (for technical users) Settings live in Tampermonkey's dropdown menu (click the extension icon in the browser toolbar → popup menu). Clean, non-intrusive, but hidden behind the extension icon. Technical users know to look there.
GM_registerMenuCommand('⚙️ 打开设置', showSettings);
⚠️ GM_registerMenuCommand items appear in the toolbar popup, NOT the dashboard. Users sometimes confuse the Tampermonkey dashboard (management page at chrome-extension://.../options.html) with the toolbar popup menu. The dashboard only shows script list with Enable/Edit/Delete toggles. GM_registerMenuCommand items appear when clicking the extension icon in the browser toolbar (right side of address bar). If a user says they can't find the menu item, they're likely looking at the dashboard instead of the toolbar popup.
Pattern B: Page-level modal (for non-technical users) A small button (e.g., gear icon) triggers a centered modal overlay. More discoverable — users find it directly on the page. Works well when paired with Tampermonkey menu as a backup.
// Add a small gear button next to enhanced elements, OR a fixed-position button
gear.addEventListener('click', () => showSettingsModal());
Best practice: provide both, unless user prefers minimal UI. Add GM_registerMenuCommand as a backup entry point AND a visible button on the page for discoverability. However, some users explicitly prefer NO page-side buttons to keep the UI clean — respect that preference and use only the Tampermonkey menu.
⚠️ Critical: Don't put settings access ON the enhanced element
NEVER add a gear/settings button next to the element you're enhancing (e.g., next to a password field). This clutters the functional UI and confuses users. Settings should be a global, separate access point:
- A floating button at the bottom-right corner of the page (away from any enhanced elements)
- The Tampermonkey dropdown menu
The user sees the enhanced element as a functional tool — mixing in configuration controls there breaks the mental model.
Password Tool: Two-Config-Modes Pattern (alwaysShow removed)
For password display/copy tools, two mutually exclusive modes cover all use cases. alwaysShow was removed in v3.1.0 because permanently changing input.type to "text" breaks form submission on many platforms (banking, social media, e-commerce sites detect type changes and reject submissions or send empty values).
| Mode | Eye Button | Input Display | Copy Button | Config Key |
|---|---|---|---|---|
| Default | ✅ Toggle | Dots ↔ Plaintext | ✅ Real password | — |
| Hide Eye | ❌ Hidden | Dots | ✅ Real password | hideEye: true |
Logic in enhance():
if (c.hideEye) {
// dots only, no eye button // password type, no eye button
} else {
// default: eye toggle + copy // eye button toggles type
}
// copy button always appended (reads input.value directly)
Key design decisions:
input.valuealways contains the real password — never modified for display- Copy button reads
input.valuedirectly, nobox._realneeded - No encryption/obfuscation — keep it simple, avoid form submission bugs
⚠️ Why alwaysShow (permanent type change) was removed
Permanently setting input.type = 'text' on password fields breaks login flows on many platforms:
- Form submission validation — platforms check
input.type === 'password'before submitting; if not password, they reject or send empty values - Browser autofill — relies on
type="password"to detect and populate password fields; changing type disables autofill - Security detection — some platforms (banks, government sites) periodically check password field type and block login if tampered
- Framework state — React/Vue may detect type changes and trigger re-renders or validation errors
Safer alternative for visual-only display: Use CSS -webkit-text-security: none to visually show plaintext while keeping type="password" unchanged. Chrome/Edge/Safari support this; Firefox does not.
Lesson learned: Eye toggle (user-initiated) and hover preview (temporary) are safe because they're transient and user-controlled. Permanent automated type changes are the danger.
Mouse-Hover Password Reveal Pattern
A UX enhancement: temporarily show the password as plaintext when the mouse hovers over the input, hide it again when the mouse leaves. Password managers (1Password, Bitwarden) use this pattern.
// Add to enhance() — wire hover events on the input element
let _hoverVisible = false;
input.addEventListener('mouseenter', () => {
if (input.type !== 'password') return; // already visible, skip
input.type = 'text';
_hoverVisible = true;
});
input.addEventListener('mouseleave', () => {
if (!_hoverVisible) return;
input.type = 'password';
_hoverVisible = false;
});
// Also hide on blur (user clicks away or tabs out)
input.addEventListener('blur', () => {
if (_hoverVisible) {
input.type = 'password';
_hoverVisible = false;
}
});
Design considerations:
- Don't conflict with the eye toggle button — if the user manually toggled visibility via the eye button, mouse hover should NOT override that state
- Track both hover state and manual toggle state separately
- The
blurhandler ensures the password hides even if the user tabs out without moving the mouse away - Make this feature configurable (default: enabled, can be disabled via settings)
- This works alongside the three-config-modes pattern — it's an additional convenience layer
Settings Modal: Isolation & Robustness
Building a settings modal that works reliably across all websites requires extra care. Website frameworks and CSS can interfere badly.
Rule 1: Build with createElement, NEVER innerHTML
React, Vue, Angular, and other frameworks intercept DOM mutations. When you set innerHTML on an element and append it to the body, the framework's virtual DOM may re-render it in unexpected locations (e.g., near the password field instead of as a centered modal).
// ❌ DANGEROUS: Frameworks can hijack this
mask.innerHTML = '<div class="modal">...</div>';
document.body.appendChild(mask);
// ✅ SAFE: Pure DOM API, no framework interference
const panel = document.createElement('div');
panel.textContent = 'Settings'; // or build child elements
mask.appendChild(panel);
document.body.appendChild(panel);
Rule 2: Pause MutationObserver while modal is open
Appending the modal to document.body triggers the MutationObserver, which may re-scan the page and cause elements near the password field to be re-enhanced or re-rendered.
let _obsPaused = false;
function openSettings() {
_obsPaused = true;
// ... build and append modal ...
}
function closeSettings() {
// ... remove modal ...
_obsPaused = false;
}
// In observer callback:
new MutationObserver(() => {
if (_obsPaused) return; // skip while modal is open
clearTimeout(_st); _st = setTimeout(scan, 300);
});
Rule 3: Inline styles with !important for critical positioning
Websites often override position: fixed with their own CSS. Use inline styles with !important to ensure the modal stays centered:
mask.setAttribute('style',
'position:fixed!important;top:0!important;left:0!important;right:0!important;bottom:0!important;background:rgba(0,0,0,0.35)!important;z-index:999998!important;display:flex!important;align-items:center;justify-content:center!important;'
);
Rule 4: Re-entry lock
GM_registerMenuCommand callbacks may fire twice in some Tampermonkey versions. Add a flag to prevent double-opening:
let _opening = false;
function openSettings() {
if (_opening) return;
_opening = true;
// ... build modal ...
// On close/save:
_opening = false;
}
Rule 5: Event isolation Stop propagation on the modal panel and action buttons to prevent clicks from bubbling to the host page:
panel.addEventListener('click', e => e.stopPropagation());
saveBtn.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation();
saveConfig(...);
closeSettings();
});
⚠️ Critical: Settings UI must survive functional gates
When a script has site-specific activation (e.g., shouldRun()), the settings panel MUST still be accessible on all pages. The common bug is putting if (!shouldRun()) return; at the top of the IIFE, which kills the settings UI when the current site isn't in the whitelist.
// ❌ BROKEN: settings UI never registers when shouldRun() is false
if (!shouldRun()) return; // exits entire IIFE
GM_registerMenuCommand('⚙️ 打开设置', openSettings); // never reached
start(); // never reached
// ✅ CORRECT: separate settings UI from functionality
GM_registerMenuCommand('⚙️ 打开设置', openSettings); // always registered
refreshMenu(); // always registered
if (shouldRun()) {
start(); // only保活 is gated
}
Design principle: Settings access is a meta-concern, not a feature. It should never be gated by feature-level checks.
Shadow DOM: The Definitive Solution
Even with createElement + paused observer + inline !important, some sites still break. Shadow DOM is the robust fallback when all else fails:
const host = document.createElement('div');
host.id = 'pw-settings-host';
host.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:999998;';
const shadow = host.attachShadow({ mode: 'closed' });
shadow.innerHTML = `<style>/* all styles here */</style><div class="mask">...</div>`;
// Events, save logic all inside shadow root
document.body.appendChild(host); // host is empty div — frameworks see nothing
Why it works: the host div is empty, so frameworks don't re-render. All content lives inside the shadow, invisible to host CSS/JS. No observer pause needed.
Default recommendation: use Shadow DOM for settings modals. It works everywhere.
11. Dev/Prod Branch Workflow
For scripts with @updateURL auto-update:
dev branch → develop/test → merge to main → bump @version → users get update
Critical: do NOT change @version and push to main during development. Users will receive unfinished code.
@updateURL and @downloadURL must always point to main branch.
File naming alignment
Script file name should match its documentation file name and folder name:
- Script:
show-password/show-password.user.js - Docs:
show-password/docs/show-password.md - Icon:
show-password/assets/icon-128.png
11. GitHub Repo Creation
When gh CLI is available, use it directly. When it's not, use the REST API with gh auth token:
# Preferred: use gh CLI
gh repo create repo-name --public --description "..." --clone
# Fallback: REST API with gh auth token
gh auth token | xargs -I{} curl -s -X POST \
-H "Authorization: Bearer {}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/user/repos \
-d '{"name":"repo-name","description":"...","auto_init":true}'
# Clone (use HTTPS, not embedded tokens)
git clone https://github.com/OWNER/repo-name.git
Pitfall: Branch name mismatch (master vs main)
New repos created via API default to main on GitHub, but local git clone may checkout master. This causes:
@updateURL/@downloadURLin scripts point tomain→ 404 if repo still onmaster- README install links break
Fix after clone:
git branch -m master main # rename local branch
git push -u origin main # push the correctly-named branch
# Set default branch via API
gh api -X PATCH /repos/OWNER/REPO -f default_branch=main
# Delete old remote branch
git push origin --delete master
git branch --set-upstream-to=origin/main main
13. UI Minimalism & Code Minimization
Every UI element must justify its presence. When you add a higher-level access point (e.g., a settings panel), remove lower-level duplicates (e.g., per-element gear buttons). Users expect a clean interface — each button should have a unique purpose.
User preference (strong): Small-feature scripts should be as short as possible. "本来就是一个小功能,先让代码量尽可能的少" — compress CSS, remove unused assets, simplify logic branches. Target: if you can cut a line without losing functionality, cut it.
User preference: UI style consistency across scripts. Toast/notification styles must match across all scripts in the same repo. When user says "跟 show-password 一致", apply the same CSS: position:fixed; bottom:24px; left:50%; transform:translateX(-50%); background:#333; color:#fff; padding:8px 20px; border-radius:6px; font:13px/1.4 -apple-system,sans-serif;. Don't introduce different toast positions/styles per script.
Checklist before shipping:
- Does every visible button serve a unique function?
- Is there redundant access to the same feature from multiple places?
- Are unused icons, variables, and dead code removed?
- Is the code as short as it can be without sacrificing readability?
- Can any CSS be compressed (remove whitespace, shorten class names in Shadow DOM)?
8. Data-Layer Interception Patterns
When the goal is not DOM manipulation but intercepting/modifying data before the app processes it, hook into data-layer APIs.
JSON.parse Hook (API Response Interception)
Many SPAs fetch JSON from APIs and parse it via JSON.parse. Hooking this method lets you inspect and modify data at the earliest possible point — before the framework touches it.
Core pattern:
const _parse = JSON.parse;
JSON.parse = function(data, ...args) {
const parsed = _parse.call(this, data, ...args);
// Inspect parsed object, modify if needed
return parsed;
};
When to use this:
- Server returns data with both "desired" and "unwanted" fields (e.g., watermarked + unwatermarked URLs)
- You want to replace values before the frontend renders them
- DOM-level interception is too late (the app has already committed to a value)
Recursive key search (to find target data in deeply nested JSON):
function findAllByKey(obj, key) {
const results = [];
if (typeof obj !== 'object' || obj === null) return results;
if (Array.isArray(obj)) {
obj.forEach(item => results.push(...findAllByKey(item, key)));
} else {
if (key in obj) results.push(obj[key]);
Object.values(obj).forEach(v => results.push(...findAllByKey(v, key)));
}
return results;
}
Platform-specific adaptation: Each platform stores target data under different field names. You must analyze the API response to find the right keys. Common patterns:
imageUrl/image_ori.url→ watermarked versionoriginalImage/image_ori_raw.url/rawUrl→ unwatermarked version
Whitelist to avoid side effects: Only intercept responses from your target domain:
JSON.parse = function(data, ...args) {
const parsed = _parse.call(this, data, ...args);
if (location.hostname !== 'target-site.com') return parsed;
// ... modify parsed ...
return parsed;
};
Reference: Platform-specific watermark removal implementations exist for various AI image generation platforms. The general pattern of hooking JSON.parse applies universally.
fetch / XMLHttpRequest Hook
For APIs that use fetch() or XHR, hook before parsing:
const _fetch = window.fetch;
window.fetch = async function(...args) {
const resp = await _fetch.apply(this, args);
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
if (url && url.includes('/api/generate')) {
const cloned = resp.clone();
const json = await cloned.json();
// Modify json, return new Response
return new Response(JSON.stringify(json), resp);
}
return resp;
};
When to use fetch hook vs JSON.parse hook:
| Aspect | JSON.parse hook | fetch hook |
|---|---|---|
| Catches | All JSON parsing | Only fetch calls |
| Granularity | Broad (catches all parsing) | Narrow (specific URLs) |
| Risk of side effects | Higher (affects all JSON.parse) | Lower (targeted) |
| Data access | After parsing (object) | Before/after (Response) |
Recommendation: Prefer fetch hook for new scripts (lower side-effect risk). Use JSON.parse hook when you can't identify the API endpoint (catch-all approach).
Cookie Header Interception (capture HttpOnly cookies)
document.cookie only returns non-HttpOnly cookies. To capture the full Cookie header as it appears in F12 Network requests (including HttpOnly), intercept XHR/fetch to capture the Cookie request header.
Pattern: Hook open → setRequestHeader → send chain. Only capture the first matching same-origin request.
const _origin = location.origin;
let _capturedCookie = '';
// Track headers per XHR
const _origSetHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.setRequestHeader = function(name, value) {
if (!this._ckClipHeaders) this._ckClipHeaders = {};
this._ckClipHeaders[name.toLowerCase()] = value;
return _origSetHeader.call(this, name, value);
};
const _origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
this._ckClipUrl = url;
return _origOpen.apply(this, arguments);
};
const _origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function() {
if (!_capturedCookie && this._ckClipHeaders?.['cookie']) {
const url = this._ckClipUrl;
const sameOrigin = !url.startsWith('http') || url.startsWith(_origin);
if (sameOrigin) _capturedCookie = this._ckClipHeaders['cookie'];
}
return _origSend.apply(this, arguments);
};
// Also hook fetch for modern SPAs
const _origFetch = window.fetch;
window.fetch = function(...args) {
if (!_capturedCookie) {
try {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
const sameOrigin = !url.startsWith('http') || url.startsWith(_origin);
if (sameOrigin && args[1]?.headers) {
const h = args[1].headers;
const cookie = h instanceof Headers
? (h.get('Cookie') || h.get('cookie') || '')
: Object.entries(h).find(([k]) => k.toLowerCase() === 'cookie')?.[1] || '';
if (cookie) _capturedCookie = cookie;
}
} catch (_) {}
}
return _origFetch.apply(this, arguments);
};
Key design decisions:
- Same-origin filter —
!url.startsWith('http') || url.startsWith(origin)ensures only current-domain requests are captured - First match only —
_capturedCookieis set once, not overwritten by subsequent requests @run-at document-start— must inject before any page scripts run, otherwise early requests are missed- Fallback to
document.cookie— if no XHR request matches, fall back to the limited API
Use case: Cookie-clip tool that needs the exact Cookie header string for API replay, with HttpOnly session tokens included.
Magnet/torrent preview userscripts:
highlightLinks pattern: always attach interactive UI, not just a CSS class. When a script scans for existing <a> tags and "enhances" them, adding only a CSS class is insufficient — the original click behavior (navigation, download trigger) is still intact. The highlightLinks function must either (a) insert an interactive sibling element (button, toggle) next to the <a>, or (b) intercept the click event with preventDefault(). The magnet-preview script was broken for the most common case (magnet links already in <a> tags on torrent sites) because highlightLinks only added mp-highlight class without a preview button. Fixed by adding addPreviewBtn(a) call that inserts a 🧲 预览 button via insertAdjacentElement('afterend', btn) and uses dataset.mpBtnAdded to prevent duplicates.
b'' Python syntax leaks into JS. When writing JS code that deals with binary data, it's easy to accidentally write b'' (Python syntax) instead of new Uint8Array(0) (JS). This happened in magnet-preview line 189: info['name'] || b'' → causes SyntaxError: Unexpected token. Always review JS files for Python syntax contamination, especially after editing in a Python context.
Cloudflare-protected sites redirect CDP navigation. Sites behind Cloudflare (e.g., heimacili.org) redirect Page.navigate to the homepage even after CF challenge passes in a real browser. The detail page never loads — location.href shows the homepage, not the target URL. Test these sites manually in the user's real browser. CDP cannot bypass CF's bot detection for specific paths, even with a real Chrome instance.
CDP screenshot testing for userscript UIs: Use SSH tunnel + PUT /json/new + WebSocket inject + screenshot pattern. This approach works in container environments where browsers aren't directly available.
Pitfall: @require CDN failure blocks script load
When using @require https://cdn.jsdelivr.net/npm/webtorrent@x.y.z/webtorrent.min.js, if the CDN is unreachable (firewall, DNS, CDN outage), Tampermonkey will block the entire script from loading — not just the WebTorrent feature. The script simply won't appear in Tampermonkey.
Mitigation for optional dependencies: Load the library dynamically at runtime instead of via @require:
// Dynamically load optional dependency
async function loadWebTorrent() {
try {
const resp = await fetch('https://cdn.jsdelivr.net/npm/webtorrent@2.5.17/webtorrent.min.js');
if (!resp.ok) return null;
const code = await resp.text();
eval(code); // WebTorrent registers globally
return window.WebTorrent;
} catch (e) {
return null; // CDN unreachable → fall through to next data source
}
}
This way the script loads regardless, and WebTorrent is used only when available.
Pattern: Multi-Source Data Fetching with Fallback
When a userscript needs data that no single API provides completely, fetch from multiple sources in parallel and merge results:
async function fetchMagnetInfo(magnetUrl) {
const hash = extractInfohash(magnetUrl);
const results = { screenshots: [], fileList: [], name: '', size: 0, sources: [] };
// Fire all sources in parallel
const [whatslink, itorrents] = await Promise.allSettled([
fetchWhatslink(magnetUrl), // ~1s, returns screenshots + name/size
fetchItorrents(hash), // ~2-4s, returns file list
]);
// Merge results from whichever sources succeeded
if (whatslink.status === 'fulfilled' && whatslink.value) {
results.screenshots = whatslink.value.screenshots;
results.name = whatslink.value.name;
results.size = whatslink.value.size;
results.sources.push('whatslink');
}
if (itorrents.status === 'fulfilled' && itorrents.value) {
results.fileList = itorrents.value.files;
results.sources.push('itorrents');
}
return results;
}
Key benefits:
- No single API failure blocks the whole preview
- Parallel requests minimize total wait time (~4s max, not 1+4=5s)
Promise.allSettledensures all results are collected regardless of failures- Display
sourcesbadge so user knows data provenance
Network auto-detection can run on script load to determine which sources are reachable:
async function detectNetwork() {
const tests = [
{ name: 'whatslink', url: 'https://whatslink.info', direct: true },
{ name: 'itorrents', url: 'https://itorrents.org', direct: true },
{ name: 'btdig', url: 'https://btdig.com', direct: false }, // needs proxy
];
// ... ping each, mark reachable/unreachable ...
}
Pitfalls
- Performance: Recursive search on large JSON payloads can be slow. Add early-exit conditions.
- Double parsing: Some frameworks call
JSON.parsemultiple times. Your hook fires for each call — check if you've already processed the data. - Async data: If the app caches parsed data, hooking JSON.parse won't affect already-cached values. Hook early (via
@run-at document-start).
9. Common Pitfalls Summary
| Pitfall | Fix |
|---|---|
| Duplicate buttons on re-run | Use el.dataset.flag to mark processed elements |
| Click events bubble to host page | Always e.preventDefault() + e.stopPropagation() |
| MutationObserver perf | Debounce at 200-300ms |
| Clipboard fails silently | Use GM_setClipboard → navigator.clipboard → textarea fallback chain |
| Updates not detected | Must increment @version before pushing |
| Script breaks on site update | Keep DOM queries flexible; use multiple selectors |
| Branch mismatch (master/main) | Align local branch name with remote default; update @updateURL accordingly |
| Settings not persisting | Ensure @grant GM_getValue and GM_setValue are declared |
| Settings appear in wrong location | Use createElement (not innerHTML), pause observer, inline styles with !important, re-entry lock |
| GM_registerMenuCommand fires twice | Add _opening re-entry flag to prevent double modal creation |
| Framework hijacks innerHTML (React/Vue) | Build UI with pure DOM API, never innerHTML for modal/panel content |
| Website CSS overrides position:fixed | Use inline styles with !important on all positioning properties |
| Settings button on enhanced element | Put settings as global access (floating button or Tampermonkey menu), NOT next to the element being enhanced |
| Shadow DOM needed for settings | When createElement+observer+inline styles still fail, use Shadow DOM — host div is empty, frameworks see nothing |
| Settings UI gated by functional check | NEVER put if (!shouldRun()) return; before settings UI registration. The settings panel must always be accessible — separate the functional gate from the UI. See pattern below |
| Closed shadow DOM blocks CDP testing | attachShadow({ mode: 'closed' }) returns null for element.shadowRoot from external JS/CDP. For CDP testing, either use mode: 'open' temporarily, or test visually via screenshot rather than DOM queries |
| Push to main during development | Use dev branch; only merge+push main when releasing with bumped @version |
SKIP set includes 'A' — magnet links already in <a> are skipped |
If SKIP tag set has 'A', processTextNodes won't process magnet links already wrapped in <a> tags (the most common case on torrent sites). Either remove 'A' from SKIP, or handle <a> elements separately — highlightLinks adds mp-highlight class, but must also bind click event to showPreview and call e.preventDefault() to intercept navigation. See magnet-preview bug investigation 2026-07-14. |
| File naming mismatch | Script name should match doc name (show-password.user.js ↔ docs/show-password.md) |
| Conditional element append throws | Never appendChild a variable outside the if block where it's defined — see Pitfall below |
closeSettings uses fragile selector |
Don't find settings host via querySelector('[style*="z-index:..."]') — other elements may match. Keep a module-level reference (let _hostEl = null) and remove that directly |
| Tampermonkey can't detect update | See pitfall below — GitHub CDN caching, version format, reinstall workarounds |
| Missing @icon | Add @icon pointing to a 128x128 PNG in the repo (assets/icon-128.png) |
| Unused code / dead variables | User expects minimal code. Remove unused icons, variables, and functions immediately. "代码尽量精简" |
| Overlay div styling doesn't match input | getComputedStyle misses edge cases (inherited bg, box-shadow, line-height). ~20% of sites show visible "layer leak". Consider removing the feature instead of patching endlessly |
Permanent input.type change breaks login |
Never permanently set input.type = 'text' on password fields. Platforms detect type changes and reject form submissions, disable autofill, or block login. Use transient changes only (user-click toggle, hover preview). Removed alwaysShow from show-password v3.1.0 for this reason. |
| Modifying parent position breaks layout | Don't set wrapper.style.position = 'relative' on arbitrary parents — static frames, forms, and grids break. Use inline-flex sibling insertion instead (see "Adding buttons next to existing elements") |
closest('...form...') too broad |
Including <form> in closest() matches the whole form — position:relative on it breaks all children. Use 'div,span,td,li,label,p' |
| Reimplementing Tampermonkey-native features | @exclude/@include handle URL filtering natively — don't write custom blacklist/whitelist code. Check Tampermonkey docs before building any feature |
| Container IP vs localhost | 127.0.0.1 inside container ≠ host's 127.0.0.1. Use hostname -i to get container IP visible from host browser |
chrome-extension:// navigation blocked |
Can't open extension pages directly via CDP. Use Tampermonkey's .user.js interception flow (Level 2) instead |
| Multiple Tampermonkey installed | Both Standard and BETA inject scripts → double notifications, duplicate menus. Check chrome://extensions and disable one |
| BETA "Allow User Scripts" off | Tampermonkey BETA requires this setting enabled. Scripts install but don't execute without it. Check BETA settings page |
| GM_registerMenuCommand not visible | User may be looking at the dashboard (management page) instead of the toolbar popup. Menu items appear when clicking the extension icon, not in the dashboard |
| Missing @noframes causes duplicate execution | Pages with iframes (e.g., ads, analytics) cause the script to inject in both main frame and iframe. Symptoms: clicking one menu item triggers two notifications (e.g., "已复制 8 个" + "已复制 4 个"). Fix: add // @noframes to metadata header |
| Tampermonkey BETA "Activating setting" menu item | BETA version adds a built-in "Activating setting" item in the script menu. It's not from user scripts and can't be removed. Ignore it. |
| Browser switch wipes Tampermonkey | Switching from Chromium to Google Chrome (or any browser profile change) creates a fresh extension environment. All installed Tampermonkey scripts are gone — they don't carry over. The user must reinstall every script. Check Tampermonkey's installed scripts page to confirm; don't assume scripts from the old browser are still there. |
| Install dialog button is INPUT, not BUTTON | Tampermonkey's ask.html dialog uses <input type="button" class="button install">, NOT <button>. document.querySelector('button.install') returns null. Use document.querySelectorAll('input') and match by .value ("Install" / "Update" / "Reinstall"). |
| Install dialog text varies by state | Button text changes: "Install" (first time) → "Update" (version bump) → "Reinstall" (same version). Also scan for all three. |
| User prefers confirm-first on unclear requests | When unsure about user intent (e.g., "弹框优化" — toast style vs. menu structure), ask before coding. "不清楚的知道先问清楚我" |
| @updateURL/@downloadURL for personal scripts | REMOVE them for personal-use scripts. They cause Tampermonkey badge to grow indefinitely, unnecessary network calls, and provide zero benefit for manual-install scripts. See Section 2. |
| Safe Event Simulation (keep-alive) | See pattern below — use isTrusted override, simulate only modifier keys, idle detection before activation |
| JSON.parse hook fires for ALL parsing | Add domain whitelist (location.hostname !== 'target') to avoid modifying unrelated data. See Section 8 |
| Recursive search slow on large JSON | Add early-exit: stop recursion when depth > N or when target key is found |
@run-at document-idle misses early parsing |
Use document-start for data-layer hooks — SPA frameworks may parse JSON before DOMContentLoaded |
| Cookie interception misses auto-added cookies | Browser adds Cookie header at network level, not via JS. If page uses vanilla XHR without setRequestHeader('Cookie', ...), interception won't capture it. Works for modern SPAs that explicitly set headers. |
| Platform field names change | Field names (e.g., image_ori_raw) are undocumented and change without notice. Test periodically |
| Cloudflare CDP redirect | Page.navigate to CF-protected detail pages redirects to homepage even in real Chrome. Can't test userscripts on these sites via CDP — must test manually. |
b'' Python syntax leaks into JS |
When writing JS that deals with binary data, Python b'' syntax can accidentally appear (e.g., `info['name'] \ |
| Preview overlay creates dead-loop via MutationObserver | If a preview modal renders the magnet link as text inside the overlay DOM, and the MutationObserver scans subtree:true on document.body, the observer detects the new overlay nodes and calls processAll() on them — which highlights the magnet link text and adds preview buttons inside the preview, creating an infinite nesting loop. Fix: (1) Remove magnet link rendering from the overlay entirely (copy button in header is sufficient). (2) Skip .mp-overlay nodes in MutationObserver: if (!n.closest('.mp-overlay')) processAll(n). |
| Progressive rendering: info first, images async | Don't wait for all data sources before showing the preview. Render the info card and file list immediately, use SVG placeholder images with data-src, then asynchronously load real images via new Image().onload → swap img.src. This cuts perceived load time from ~5s to <1s. |
| Competitive rendering (竞速渲染) | When fetching from multiple data sources, emit partial results as each source completes. Use an emitter pattern (fetchAll(magnet, emit)) instead of Promise.all. Each source pushes its data into a shared result object and calls emit(phase) — the UI renders whatever is available immediately. See references/competitive-rendering.md. |
| Click-to-enlarge images > hover zoom | User prefers click-to-view for screenshots/images, not :hover CSS transform scale. Open a full-size overlay on click. Hover zoom is considered heavy/unnecessary animation. |
| Pure CSS spinner | Use a simple 3-line spinning circle (.mp-spin: border + border-top-color + border-radius:50% + animation: spin) instead of multi-dot bouncing animations. User prefers "好看而且代码少的动画" — clean and minimal. |
node --check for syntax validation before TM install |
Run node --check script.user.js to catch syntax errors (b'', duplicate const, missing brackets) before installing into Tampermonkey. This catches errors that would silently block script injection. Always do this before any TM install. |
Duplicate const declarations |
Accidentally declaring const copyBtn = ... twice in the same scope causes SyntaxError: Identifier has already been declared. This blocks the entire script. Use node --check script.js to catch these before installing. |
| Tampermonkey "missing script" after CDP injection | Editing script content via CodeMirror through CDP may not register the script in Tampermonkey's internal storage (IndexedDB/localStorage). Console shows: injected: env: missing script "UUID"!. Fix: use the proper Tampermonkey install flow (navigate to .user.js URL, click Install on the dialog) rather than injecting into CodeMirror. |
| Root-owned files in git repo block operations | If a subdirectory or file in the repo is owned by root but the git clone is owned by a different user (e.g., hermes), git operations like reset --hard, checkout, pull --rebase will fail with Permission denied: unable to unlink old file. Workaround: use git stash + git push --force to push commits without touching the root-owned file locally. For permanent fix: sudo chown -R $USER:$USER on the repo directory. |
file:// protocol not matched by @match *://*/* |
Tampermonkey's @match patterns only cover http:// and https:// (and ftp://). file:// pages will never receive the script. For local testing, always serve files via HTTP (e.g., python3 -m http.server 8765). Confirmed via CDP: 0 buttons on file:// vs full functionality on http://127.0.0.1:8765. |
| Root-owned files in git repo block operations | If a subdirectory or file in the repo is owned by root but the git clone is owned by a different user (e.g., hermes), git operations like reset --hard, checkout, pull --rebase will fail with Permission denied: unable to unlink old file. Workaround: mv the root-owned directory aside, then git checkout HEAD -- dir/ to recreate with correct ownership. The new directory will be owned by the current user. Then rm -rf the old root-owned directory (may fail on some files, which is OK — they're orphaned but harmless). |
| Root-owned git objects block normal commits | If .git/objects/ contains files owned by root, git add + git commit fails with "insufficient permission for adding an object". Fix: push via GitHub API (PUT /repos/:owner/:repo/contents/:path) using curl with a PAT token — base64-encode the file content, include the current SHA. This bypasses local git entirely. |
| Incremental DOM update (not full innerHTML replace) | When rendering results from multiple async data sources (competitive rendering), NEVER use body.innerHTML = render(data) on each emit — it wipes previously-rendered sections. Instead, create independent DOM containers (mp-info, mp-files-wrap, mp-shots-wrap) and update each one separately via infoEl.innerHTML = ..., filesWrap.innerHTML = ..., shotsWrap.innerHTML = .... Each emit callback only touches its own container. |
| Emoji vs SVG: user likes emoji copy button, SVG for structural icons | User's preference: the 📋 emoji copy button was liked; replacing it with SVG was called out ("复制的图标你给换了"). For simple utility buttons (copy, preview), emoji is fine. For structural icons (close button, file type indicators), SVG is preferred. Don't replace emoji buttons with SVG without asking. |
| Tampermonkey runs cached old version after reinstall | If user reinstalls a script but still sees old behavior (old SVG icons, old class names, old CSS), check: (1) are there duplicate script entries in TM's installed list? Delete ALL copies before reinstalling. (2) Did the user actually delete the old script before installing? Tampermonkey may silently skip reinstall if same name+version exists. Always have user verify by checking the installed scripts page shows only one entry with the correct version number. (3) GitHub Raw CDN cache: GitHub caches raw files for ~5 minutes. If the user installs immediately after push, they may get the old cached version. Wait 5 minutes or append ?t=timestamp to the URL. (4) Verify the remote file content via curl to confirm the push actually landed — GitHub API commits may succeed but raw CDN may still serve stale content. |
| Code golf: userscripts target 188 lines, not 800+ | User explicitly asked for code reduction: "整体的代码把控一下,要美观,不要一些无用的代码,能复用的也尽量复用". Magnet-preview went from 829→188 lines (-77%) by: compressing CSS to single-line, merging helper functions, removing dead code, using ternary/arrow shorthand, removing verbose comments. Target: every line must justify its existence. |
| Emoji copy button preferred over SVG icon button | User said "复制的图标你给换了" — they wanted the 📋 emoji copy button, not the SVG copy icon. When user has an existing UI they like, don't redesign it without asking. The v2 accidentally replaced 📋 with an SVG icon; user noticed immediately and asked to revert. |
Safe Event Simulation (for keep-alive / anti-idle scripts)
When simulating user activity to prevent session timeout, two pitfalls matter:
Pitfall: isTrusted property
Browser-native events have event.isTrusted === true. Programmatically created events have isTrusted === false. Some sites check this and ignore fake events.
// Workaround: override isTrusted on the event object
const evt = new MouseEvent('mousemove', { clientX: x, clientY: y });
Object.defineProperty(evt, 'isTrusted', { get: () => true });
document.dispatchEvent(evt);
Pitfall: Simulated keystrokes typing into input fields Don't simulate letter/number keydown events — they'll type into focused inputs. Use modifier-only keys:
// Safe: modifier keys don't produce character input
['Ctrl', 'Alt', 'Shift'].forEach(key => {
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
});
// Safe: mousemove at random coordinates on document body
const x = Math.random() * window.innerWidth;
const y = Math.random() * window.innerHeight;
document.dispatchEvent(new MouseEvent('mousemove', { clientX: x, clientY: y, bubbles: true }));
Pattern: Idle detection before activation Track real user activity; only simulate after idle period:
let lastActivity = Date.now();
const IDLE_THRESHOLD = 5 * 60 * 1000; // 5 minutes
// Track real user activity
['mousemove', 'keydown', 'click'].forEach(evt => {
document.addEventListener(evt, () => { lastActivity = Date.now(); }, { passive: true });
});
// Keep-alive check
setInterval(() => {
if (Date.now() - lastActivity > IDLE_THRESHOLD) {
simulateActivity(); // only fire when user is idle
}
}, 60_000); // check every minute
Pattern: Auto-detect current domain for whitelist configs
When a script uses a site whitelist, help the user by auto-detecting the current domain in the settings panel:
const currentDomain = location.hostname.replace(/^www\./, '');
const isCurrentInList = siteList.some(s => currentDomain === s || currentDomain.endsWith('.' + s));
// In settings panel HTML:
// Show current domain with add/remove buttons
`<div class="cur">
<div class="cd">${currentDomain}</div>
<button id="s-addcur">${isCurrentInList ? '✓ 已添加' : '+ 加入白名单'}</button>
${isCurrentInList ? '<button id="s-rmcur">移除</button>' : ''}
</div>`
Why this matters: Users shouldn't have to type domain names manually. Auto-detection + one-click add is far more user-friendly. The replace(/^www\./, '') normalization avoids duplicate entries for www.example.com vs example.com.
Pitfall: Conditional DOM element creation
When creating elements inside an if block, DO NOT reference them outside that block. The variable is undefined when the condition is false.
// ❌ BROKEN: tog is undefined when alwaysShow=true
if (!cfg.alwaysShow) {
const tog = document.createElement('button');
// ... setup tog ...
box.appendChild(tog);
}
box.appendChild(tog); // 💥 ReferenceError!
// ✅ CORRECT: append inside the same block
if (!cfg.alwaysShow) {
const tog = document.createElement('button');
// ... setup tog ...
box.appendChild(tog); // append here, inside the if
}
// Don't reference tog outside
This is especially common with feature toggles (e.g., "show/hide advanced buttons"). Always trace the variable scope.
