# Browser Userscript Development

> Use when writing Tampermonkey/userscripts for SPAs.

- Skill: `wcpaka-lgtm/browser-userscript-development` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add wcpaka-lgtm/browser-userscript-development`
- Raw SKILL.md: https://api.skillmd.com/api/skills/wcpaka-lgtm/browser-userscript-development/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: wcpaka-lgtm (https://skillmd.com/u/wcpaka-lgtm)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/wcpaka-lgtm/browser-userscript-development

---


# Browser Userscript Development (Tampermonkey / Greasemonkey)

Write userscripts that inject UI, filter content, or modify behavior
on existing websites. This skill covers the full lifecycle: initial
script → DOM discovery → iterative debugging → stable release.

## When to load

- User asks for a Tampermonkey / Greasemonkey / Violentmonkey script
- User wants to modify a website's behavior (filter, hide, restyle, add buttons)
- User says "템퍼몽키", "유저스크립트", "userscript", "greasemonkey"

## Script skeleton

Every userscript needs this header. `@match` must cover ALL URL
variants the site uses (see Pitfall #1):

```js
// ==UserScript==
// @name         <descriptive name>
// @namespace    <site domain>
// @version      1.0
// @description  <what it does>
// @match        https://example.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==
```

See `templates/spa-userscript-skeleton.js` for a full SPA-ready
boilerplate with navigation detection, debug panel, and per-site
storage.

## Workflow

### 1. Discover the REAL URL patterns FIRST

Before writing any code, confirm the actual URL the user sees.
**Do NOT guess URL patterns from the site's marketing or old docs.**

- Ask the user for the URL, OR
- Use `computer_use(action="capture", mode="vision")` on their browser
  window and read the address bar from the screenshot.

Common trap: sites migrate to new URL schemes. A site might use
`/articles` in docs but `/f-e/cafes/{id}/menus/0` in the actual SPA.
If your `isTargetPage()` check doesn't match, the script silently
does nothing — the #1 cause of "nothing changed" reports.

### 2. Discover DOM structure

For SPAs, you cannot inspect the server HTML — the DOM is built
client-side. Options in order of preference:

1. **computer_use vision**: Capture the browser window with
   `mode="vision"`, read the page structure from the screenshot.
   You can see class names in DevTools if the user has them open.
2. **Ask the user**: "F12 → right-click the element → Inspect →
   screenshot the HTML and send it."
3. **web_extract on the page URL**: Sometimes works for server-rendered
   content, but SPAs return an empty shell.

When using computer_use on Chromium-based browsers (Chrome, Edge,
Comet, Brave):
- UIA tree often times out (`Chrome_WidgetWin_1` class) → use
  `mode="vision"` instead of `mode="som"`.
- Background keystrokes may be blocked → you can't type into DevTools
  console. Read the URL and visible text from screenshots instead.
- If foreground delivery is also unsupported (older cua-driver),
  don't waste turns retrying — extract what you need from vision.

### 3. Build with a debug panel

Always include a debug toggle in v1. A floating 🔍 button that shows:
- Current URL and whether `isTargetPage()` matched
- Number of DOM rows/elements found
- Extracted names/categories
- First 3 elements' `outerHTML` (truncated to 200 chars)

This lets the user send you a screenshot of the debug output instead
of trying to describe what's wrong. See the template for implementation.

### 4. SPA navigation detection

SPAs don't reload the page. You need three hooks:

```js
// 1. Intercept pushState
const origPush = history.pushState;
history.pushState = function () {
    origPush.apply(this, arguments);
    setTimeout(onNavigate, 300);
};

// 2. Back/forward buttons
window.addEventListener('popstate', () => setTimeout(onNavigate, 300));

// 3. URL change via MutationObserver (catches replaceState too)
let lastUrl = location.href;
new MutationObserver(() => {
    if (location.href !== lastUrl) {
        lastUrl = location.href;
        onNavigate();
    }
}).observe(document.body, { childList: true, subtree: true });
```

Add a retry loop for initial load — SPA content renders asynchronously:

```js
function tryBuild() {
    buildUI();
    if (getRows().length <= 2 && retryCount < 5) {
        retryCount++;
        setTimeout(tryBuild, 1500);
    }
}
```

### 5. Per-site persistent storage

Use `GM_setValue` / `GM_getValue` keyed by site ID so settings
don't leak across sites:

```js
function loadSettings(siteId) {
    try { return JSON.parse(GM_getValue('settings_' + siteId, '{}')); }
    catch { return {}; }
}
```

## Pitfalls

1. **URL pattern mismatch (THE #1 BUG)**: The script's page-detection
   regex doesn't match the actual URL. The script loads on every page
   of the domain (`@match https://site.com/*`) but the internal
   `isTargetPage()` check fails silently. ALWAYS verify against the
   real URL from the user's browser, not from documentation.

2. **SPA content not rendered yet**: `document-idle` fires before
   React/Vue finishes rendering. Use `setTimeout` (1500–2500ms) plus
   a retry loop, not just a single delay.

3. **Class names are hashed in React/CSS-modules**: Class names like
   `Layout_CafeLayout__nUhA` include hashes that change between
   deploys. Prefer `[class*="article"]` substring selectors over
   exact class matches. Better yet, match on text content or `href`
   patterns.

4. **Board/category extraction**: In list views, the category label
   is often a small `<span>` or `<a>` with an `href` containing a
   menu/board ID. Extract from `href` attributes first, fall back to
   text content. Cross-reference with sidebar navigation links for
   known category names.

5. **Dark mode / extensions**: Dark Reader and similar extensions
   inject `data-darkreader-*` attributes and override CSS variables.
   Your injected UI should use its own hardcoded colors, not inherit
   from the page.

6. **Insertion point**: Don't append to `document.body` for inline UI.
   Find a stable anchor element (a heading, a toolbar, a known text
   string) and insert relative to it. Search by text content:
   ```js
   const allEls = document.querySelectorAll('span, div, h1, h2, h3');
   for (const el of allEls) {
       if (el.textContent.includes('known text')) {
           el.parentElement.insertBefore(myWidget, el);
           break;
       }
   }
   ```

## Verification

After delivering a script:
1. Ask the user to refresh the target page
2. If "nothing changed" → the debug panel (🔍) should still appear.
   If not even the debug button shows, the `isTargetPage()` check
   is failing — get the real URL.
3. If debug button shows but no filter UI → DOM selectors are wrong.
   Ask for a screenshot of the debug panel output.

## Site-specific references

- `references/naver-cafe-dom.md` — Naver Cafe (신형/구형) URL patterns,
  DOM structure, board name extraction strategies.

