npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

pluckor

v0.25.0

Published

Give an AI agent a real Chrome to drive, over MCP — browse, read, extract, and act on any page, getting through Cloudflare and sites that block headless/Playwright, with no automation fingerprint on reads.

Readme

pluckor

Give an AI agent a real browser. Extract from anything — even Cloudflare.

pluckor is an MCP server that hands an AI agent (Claude Code, Claude Desktop, or any MCP host) a real Chrome to drive. It launches Chrome for Testing with the Pluckor extension loaded, then exposes tools to navigate, read rendered HTML, run JavaScript, click, type, scroll, and wait — and returns the extracted data to the agent.

The page loads in a real browser on your machine — real profile, real residential IP — and reads happen through a content script, so there's no CDP and no navigator.webdriver. That gets through sites that block headless browsers and Playwright, including Cloudflare-protected pages.

# Claude Code — one step:
/plugin marketplace add pluckor/pluckor-plugin
/plugin install pluckor@pluckor

Pluckor vs. Claude for Chrome

Short version: Claude for Chrome puts Claude in your browser; Pluckor gives your agent its own. They're complementary, not competitors.

  • Whose browser — Claude for Chrome drives the everyday Chrome you already use (your profile, tabs, logins). Pluckor launches a separate, dedicated Chrome it owns, with its own persistent profile.
  • What it is — Claude for Chrome is a Claude product feature (you pairing with Claude in your session). Pluckor is an open MCP server you wire into any agent — Claude Code, Cursor, Codex, or your own harness.
  • Primary job — Claude for Chrome assists with general browsing. Pluckor is built for data extraction/automation, and for getting through anti-bot: reads use content-script/tab APIs with no CDP and no automation fingerprint, so it clears Cloudflare and sites that block headless/Playwright.
  • Isolation & scale — Pluckor gives each agent its own tabs (lanes) and even fully separate browsers (PLUCKOR_INSTANCE), so fleets of agents don't collide. Claude for Chrome is your one browser.
  • Where it runs — Pluckor is self-hosted, 127.0.0.1-only; you own the browser and the data.

The soundbite: Claude for Chrome automates your browsing; Pluckor is a browser your agent can automate. Reach for Pluckor when an autonomous agent needs a clean, scraping-grade browser as a programmable tool.


Install

Pluckor has two parts: the MCP server (the browser tools, run via npx -y pluckor mcp) and a skill (SKILL.md) — the workflow patterns and gotchas (wait for async content, robust selectors, Cloudflare handling, the persistent-login flow) that make an agent reliably good at extraction. Set up both for your tool. Requires Node ≥ 20.11; the first tool call downloads Chrome for Testing (~150 MB).

Claude Code — one step (recommended)

A plugin installs the server and the skill together:

/plugin marketplace add pluckor/pluckor-plugin
/plugin install pluckor@pluckor

MCP-only alternative: claude mcp add pluckor -- npx -y pluckor mcp, then copy SKILL.md to ~/.claude/skills/pluckor/SKILL.md.

Cursor

  1. MCP server — add to ~/.cursor/mcp.json (global) or .cursor/mcp.json (project):
    { "mcpServers": { "pluckor": { "command": "npx", "args": ["-y", "pluckor", "mcp"] } } }
  2. Skill — put SKILL.md at .cursor/skills/pluckor/SKILL.md.

Codex

  1. MCP server:
    codex mcp add pluckor -- npx -y pluckor mcp
  2. Skill — put SKILL.md at ~/.codex/skills/pluckor/SKILL.md (personal) or .agents/skills/pluckor/SKILL.md (project).

Any other MCP host

{ "mcpServers": { "pluckor": { "command": "npx", "args": ["-y", "pluckor", "mcp"] } } }

The SKILL.md ships in this package and in the plugin repo, which carries it in each tool's skill location. Cookies/logins persist across runs, so log in once and every session inherits it.


The plk CLI — a warm, shared browser

Under the hood there are two pieces: a daemon that owns the browser, and the MCP server the agent connects to. The daemon means the browser stays warm between sessions, multiple agents can share it, and you can log into sites and watch what's happening.

plk start          # launch the browser daemon in the background
plk start --tail   # …and follow its logs live
plk status         # is it running? reachable? which version? browser connected?
plk logs --tail    # follow the daemon log
plk stop           # close the browser and stop the daemon
plk restart        # stop then start — recovers a stale or outdated daemon
plk mcp            # the MCP server the agent spawns (auto-starts the daemon)

plk call <tool> [json-args]   # invoke one tool, print its JSON result
plk pipe                      # a persistent NDJSON session on stdin/stdout

You don't have to run plk startplk mcp auto-starts the daemon on first use. But starting it yourself lets you:

  • Log in once. Run plk start, log into the sites you care about in the window that opens; every agent session reuses those sessions.
  • Keep it warm. No relaunch between agent sessions.
  • Share it. Multiple concurrent MCP sessions drive the same browser.
  • Watch it. plk logs --tail shows every navigation and tool call.

(pluckor and plk are the same CLI — pluckor mcpplk mcp.)


Driving it without MCP — plk call and plk pipe

MCP is for agents. When the caller is a program — a Python orchestrator, a job runner, a shell script — call and pipe give it the same tools over the same daemon and the same warm, logged-in browser. Both auto-start the daemon exactly like plk mcp, and both resolve tools through the one registry the MCP proxy uses, so a tool that exists for an agent exists here, with the same schema, the same tab / timeoutMs params, and the same typed errors.

Channels. stdout is the machine channel and carries results only; every log, warning, and diagnostic goes to stderr.

plk call — one shot

plk call navigate '{"url":"https://example.com"}'
plk call extract  '{"container":".product","fields":{"name":"h2","price":".price"}}'
plk call list_tabs
// stdout — the result, and nothing else
{"url":"https://example.com","finalUrl":"https://example.com/","settled":true}

Exit 0 on success. On failure, exit 1 and stdout carries a machine-readable envelope (stderr gets the human line):

{"ok":false,"error":{"code":"SELECTOR_NOT_FOUND","message":"…","retryable":true}}

Branch on error.codeNO_TAB, TIMEOUT, NAV_TIMEOUT, NOT_CONNECTED, UNKNOWN_COMMAND, BAD_MESSAGE, … — never on the message text. An unknown tool name lists the real ones in error.detail.knownTools.

Quoting on Windows. PowerShell: single-quote the JSON, as above. cmd.exe has no single quotes — escape the inner ones: plk call navigate "{\"url\":\"https://example.com\"}". A caller that spawns the process with an argv array (Python's subprocess with a list) has no quoting problem at all.

plk pipe — a persistent session

call pays for a daemon connection per invocation and gets a fresh lane (so a fresh default tab) each time. pipe keeps one session open: one request per line in, one response per line out, with the connection, its tabs, and their logged-in state alive until stdin closes. This is the mode to build an orchestrator on.

// stdin
{"id":1,"tool":"open_tab","args":{"url":"https://example.com/list"}}
{"id":2,"tool":"extract","args":{"tab":"t2","container":".row","fields":{"t":"h2"}}}

// stdout
{"id":1,"ok":true,"result":{"tab":"t2","finalUrl":"https://example.com/list"}}
{"id":2,"ok":true,"result":{"records":[]}}
// …or, per request:
{"id":2,"ok":false,"error":{"code":"NO_TAB","message":"…","retryable":true}}
  • Requests are not serialized. They are dispatched as they arrive; the daemon handles each control call independently and the extension dispatches per command. Ten tabs can navigate and extract at once through one session (measured: 10 parallel navigates finish in the time of one).
  • Responses come back in completion order — correlate by id, never by position.
  • A malformed line costs that line, not the session. It gets one {"id":null,"ok":false,"error":{"code":"BAD_MESSAGE",…}} and the session carries on. Blank lines are ignored.
  • In-flight calls are drained when stdin closes, so no response is lost.
  • One session is one lane (see Multiple tabs) — every request shares one default tab, so open_tab first and pass that tab handle on every call rather than relying on the default.
  • Managing the daemon stays on the CLI: plk status, plk restart, plk logs --tail — those are commands, not tools.

Replaying a script, and resuming a halted one

The scripting tools are callable too — run_script is the reason most orchestrators want a session at all. Replay a saved script; when it halts, patch the offending step and resume from exactly where it stopped:

// stdin — replay
{"id":1,"tool":"run_script","args":{"script":{"version":1,"steps":[…]},"secrets":{"password":"…"}}}
// stdout — halted at step 2, with the failing contract and a page snapshot
{"id":1,"ok":true,"result":{"outcome":"halted","haltedAt":2,"failure":{…},"steps":[…]}}

// stdin — patch step 2, then resume from the fault
{"id":2,"tool":"run_script","args":{"script":{"version":1,"steps":["…patched…"]},"from":2}}
{"id":2,"ok":true,"result":{"outcome":"completed","startedAt":2,"data":[…]}}
  • secrets fills ${SECRET:name} in step params. Values reach the page and nothing else — they appear in no result channel, and neither mode logs request args.
  • No overall time budget. A script is many calls, so the per-call timeoutMs + 15_000 rule does not apply to it; each step carries its own, exactly as under MCP. A long replay is never cut short by the front end.
  • Safe resume. A mutating step at the from boundary is not re-fired — its state contracts are re-checked instead, so resuming can't double-submit.
  • record_start / record_stop need pipe, not call: a draft accumulates across requests, so one-shot mode could only discard it and returns a typed error saying so. Recording also assumes sequential requests — don't fan out while one is open.
# the shape of an orchestrator
proc = subprocess.Popen(["plk", "pipe"], stdin=PIPE, stdout=PIPE, text=True, bufsize=1)
proc.stdin.write(json.dumps({"id": 1, "tool": "open_tab", "args": {"url": url}}) + "\n")
resp = json.loads(proc.stdout.readline())   # match resp["id"] against what you sent

Tools

| Tool | What it does | Returns | |------|--------------|---------| | navigate | Load a URL; waits for load and settles past Cloudflare-style interstitials | { url, finalUrl, settled, onChallenge } | | get_html | Rendered HTML of the page (or a CSS selector) — content script, no CDP | { html, url, truncated } | | get_markdown | Clean, token-efficient Markdown of the page's main content (auto-detects the article/main root) — content script, no CDP. ~10× smaller than the HTML | { markdown, url, title, chars, truncated, root } | | run_js | Evaluate a JS expression; returns its value. captureConsole:true also returns the script's own logs + any thrown error | { value } (or { value, logs, error }) | | wait_for_selector | Wait until a selector appears (optionally visible), or time out | { found, waitedMs } | | snapshot | A ref-stamped map of the page's actionable elements (+ heading/landmark context) so you act by ref, not by guessing selectors. Content-script DOM walk, no CDP | { tree, count, url, title, truncated } | | click | Trusted click (isTrusted = true) at an element's center — by CSS selector or a snapshot ref | { clicked } | | type | Trusted text entry into a field (fires real input events) — by selector or ref | { typed, into } | | scroll | Scroll the page (wheel default, or CDP gesture mode) | { scrolled, mode } | | screenshot | Capture the page as an image — viewport (no CDP), scroll:true for a stitched long shot (no CDP), fullPage:true for a one-shot full page (CDP), or selector for an element (CDP). Pass filename (or save:true for a default name) to save it to disk instead of returning it inline | image + { width, height, format, bytes }, or { path, filename, … } when saved | | extract | Structured data from a field→selector map — add container for one record per element (a listing). No code, no CDP | { record } or { records, count } | | extract_links | Harvest deduped, absolute links (filter by pattern) for list→detail crawls. No CDP | { links, count } | | capture_requests | Inspect the page's network — recent request metadata (no CDP), or record/stop a CDP session to capture response bodies (the JSON an API returned) | { requests, count } | | wait_for_response | Block until a request matching pattern completes — a smarter wait for API-driven pages. No CDP | { matched, url, status, waitedMs } | | wait_for_network_idle | Block until the network goes quiet (≤ threshold in-flight for idleMs) — the "data loaded" wait when no single request/selector fits. No CDP | { idle, inFlight, waitedMs } | | wait_for_human | Hand off to a human for an interactive Cloudflare Turnstile, login, or 2FA — blocks until the challenge clears or an until selector appears. Focuses the window, notifies, and shows an on-page banner. No CDP | { resolved, reason, waitedMs, onChallenge } | | capture_console | Read the page's own console output + uncaught errors, buffered since load. No CDP | { entries, count } | | press_key | Press a key (trusted) — Enter to submit, Escape, Tab, arrows, or a character; optional selector/ref to focus first | { pressed } | | select_option | Choose an option in a native <select> by value/label/index (fires change — React/Vue see it) — by selector or ref. No CDP | { selected, value, label } | | hover | Move the mouse over an element to reveal menus/tooltips/hover content — by selector or ref | { hovered } | | go_back / go_forward | Navigate history and wait for load. No CDP | { url, finalUrl, settled, onChallenge } | | reload | Reload the tab (optional bypassCache) and wait for load. No CDP | { url, finalUrl, settled, onChallenge } | | wait_for_function | Poll a JS expression until it's truthy, or time out | { satisfied, value, waitedMs } | | get_cookies / set_cookie | Read/set cookies for the page (persist in the profile). No CDP | { cookies, count } / { set, name } | | get_local_storage / set_local_storage | Read/write the page's localStorage. No CDP | { items, count } / { set, key } | | download | Download a URL to disk (a "Download CSV/export" or media file) and return the path. No CDP. filename is relative to the download folder | { path, filename, mime, bytes } | | save_pdf | Render the current page to a PDF and save it to disk (CDP). filename relative to the download folder | { path, filename, bytes } | | open_tab | Open a new tab (optionally load a url) and get a handle to drive it. No CDP | { tab, url, finalUrl, settled, onChallenge, isLaneDefault } | | list_tabs | List your tabs (handle, url, title, active, which is the default). No CDP | { tabs, count } | | close_tab | Close one of your tabs by handle. No CDP | { closed, tab } | | run_script | Replay a workflow: an ordered list of tool-call steps, each optionally guarded by pre / verified by an expect effect contract. from resumes from a step. No CDP beyond what each step's own tool uses | { ok, outcome, data, assertions, steps, failure? } | | record_start / record_stop | Record your tool calls into a replayable draft script, with candidate assertions inferred from what actually changed | { recording } / { script } |

Reads (navigate, get_html, get_markdown, wait_for_selector, snapshot, extract, extract_links, wait_for_response, wait_for_network_idle, wait_for_human, capture_console, select_option, go_back, go_forward, reload, get_cookies, set_cookie, get_local_storage, set_local_storage, download, capture_requests metadata, and screenshot's default viewport/scroll modes) never touch CDP. The interaction tools (run_js, click, type, press_key, hover, wait_for_function, save_pdf, scroll gesture, screenshot's fullPage/selector modes, and capture_requests's body-recording session) attach chrome.debugger only while they run — which shows Chrome's "started debugging this browser" infobar during that call.

run_script's assertions are CDP-free too — they read the page with chrome.scripting and the passive network buffer — so guarding a scripted workflow adds no automation fingerprint of its own. A step only uses CDP if the step's own tool does.

Every tool also takes an optional timeoutMs to override its default time budget — raise it for a slow navigate or a long run_js, or lower it to fail fast.

Scripting — replay a workflow, and know when it breaks

Once a workflow is known, an agent shouldn't re-derive it every time. run_script replays an ordered list of tool-call steps in one call — no model turn per step — and each step can carry an effect contract that says what it was supposed to cause:

{ "version": 1, "steps": [
  { "tool": "navigate", "params": { "url": "https://example.com/login" } },
  { "tool": "type",  "params": { "selector": "#user", "text": "${SECRET:user}" } },
  { "tool": "type",  "params": { "selector": "#pass", "text": "${SECRET:pass}" } },
  { "tool": "click", "params": { "selector": "button[type=submit]" },
    "expect": [
      { "assert": "network", "method": "POST", "url": "**/login", "status": 302 },
      { "assert": "url", "pathEquals": "/" },
      { "assert": "selector", "selector": "a[href='/logout']", "state": "present" }
    ] },
  { "tool": "extract", "params": { "container": ".row", "fields": { "title": "h3" } },
    "expect": [ { "assert": "count", "selector": ".row", "min": 1 } ] }
] }

Assertions work at five layers — network (method + URL glob + status), url, selector, text, count — each with onFail: halt (default), warn, or retry. Prefer network where a step hits a server: it's the contract between client and server, where the state change actually happens, so it catches the failures a DOM check can't — a page that renders "Success!" over a 402.

The result has two channels, joined but never blended: your data in data, the verdicts in assertions, plus a per-step trace.

When reality diverges, it hands back the yoke. A failed halt contract stops the run and returns a failure payload — the failing contract plus a page snapshot at the point of divergence — so the agent can fix the step and call run_script { from: <haltedAt> } to resume from the fault, not the top. A mutating step at the resume boundary is never silently re-fired, so you don't double-submit.

You don't have to write scripts by hand. record_start, do the workflow normally, record_stop — you get a draft with candidate assertions inferred from what actually changed (the real status a request returned, the URL it landed on, how many rows came back), and password fields redacted to ${SECRET:…}. Curate and save it.

Scripts are deliberately linear — no loops, no branching, no data flowing between steps. A script does the deterministic part; the moment something needs a decision, that's the agent's job again.

Credentials go in secrets at call time and are never stored in the script.

Multiple tabs

The daemon owns one shared browser, but tabs are isolated per connection: each plk mcp process is a lane with its own default tab. Separate agents that each run their own plk mcp share the one warm, logged-in browser without colliding — no setup, existing single-tab code just works.

Agents: work only in tabs you opened. Subagents typically inherit their parent session's MCP connection — so they share a lane and its default tab, and will navigate it out from under each other. The collision is silent: you get the other agent's page back under your own request, with no error. Pluckor can't detect this (one connection carries no per-caller identity), so the discipline is the control:

  1. open_tab before your first navigation, and pass that handle on every call.
  2. Keep using it — one tab for the session, not one per step.
  3. Never drive a tab you didn't open — not another agent's, and not one the human has open. list_tabs showing a tab is not permission to use it.
  4. Open as many as you need, and close_tab each one you opened.
  5. On any NO_TAB your handle is dead (tab t2 was closed … if someone closed it, unknown tab handle: t2 if you closed it or it was never yours) — open a new tab, never fall back to whatever tab happens to be there.

open_tab returns a tab handle, and every tool accepts an optional tab to target it (omit it for your lane's default tab).

open_tab { url: "https://example.com/list" }   → { tab: "t2" }
extract  { container: ".row", fields: {…}, tab: "t2" }
navigate { url: "https://example.com/item/1", tab: "t3" }
list_tabs                                        → your tabs
close_tab { tab: "t2" }

Handles and default tabs are lane-scoped: a connection only sees and drives its own tabs.

Multiple browsers (separate instances)

When agents are doing completely unrelated work and shouldn't share a session at all, run separate instances — each its own browser, profile, logins, daemon, and ports. Set PLUCKOR_INSTANCE (a name or number):

// two MCP servers → two independent browsers
{
  "mcpServers": {
    "pluckor":      { "command": "pluckor", "args": ["mcp"] },
    "pluckor-work": { "command": "pluckor", "args": ["mcp"],
                      "env": { "PLUCKOR_INSTANCE": "work" } }
  }
}

PLUCKOR_INSTANCE=work gives that daemon its own home (~/.pluckor-bridge-work), profile, ports, and auth token — derived deterministically, so plk/plk mcp agree with no discovery. Unset = the default instance, unchanged.

  • Ports: default 9234/9235; integer N+2N; a name → a stable hash. Override with PLUCKOR_WS_PORT / PLUCKOR_CONTROL_PORT.
  • The Chrome download cache is shared (no re-download); profiles/logins are per-instance. plk status / plk stop target the instance in the env.

Rule of thumb: one browser, many lanes/tabs (above) for related work that can share logins; separate instances for unrelated work that shouldn't.

Running in a container / headless Linux

Pluckor runs a real, headful Chrome — never --headless (headless has a detectable fingerprint, which defeats the point). On a server or container with no physical display, you keep Chrome headful and give it a virtual display (Xvfb):

  • Provide a display: Xvfb :99 & export DISPLAY=:99 (or wrap the command).
  • Set PLUCKOR_CHROME_FLAGS="--no-sandbox --disable-dev-shm-usage" — containers need --no-sandbox (no user namespaces as root) and --disable-dev-shm-usage (tiny /dev/shm).
  • Build for linux/amd64 — on Linux, Chrome for Testing is amd64-only (there's no arm64 build), so on Apple Silicon / arm hosts build/run the image with --platform linux/amd64.
  • Install Chrome's shared libraries (libnss3, libgbm1, libatk-bridge2.0, fonts, …) and unzip.

A ready-to-adapt Dockerfile and entrypoint live in examples/docker/. This exact setup is verified end-to-end in CI-style testing (headful Chrome under Xvfb drives a page inside the container).

Driving it from outside the container

By default the daemon+browser and the plk mcp proxy must share one loopback (the control channel is 127.0.0.1-only). To keep the browser+daemon in the container and drive it from an agent on the host (or another machine), expose the control port with a token:

  • In the container: PLUCKOR_CONTROL_BIND=0.0.0.0 and PLUCKOR_CONTROL_TOKEN=… (the daemon refuses to start exposed without a token), and publish the port — docker run -p 127.0.0.1:9235:9235 …. Start the daemon inside (plk start).
  • On the host, point the proxy at it: PLUCKOR_CONTROL_HOST=127.0.0.1 (the published port), the same PLUCKOR_CONTROL_TOKEN, and PLUCKOR_ATTACH=1 (don't spawn a local daemon). For a daemon on another machine, set PLUCKOR_CONTROL_HOST to its host/IP (and put the port on a trusted network or behind a tunnel — the token is the gate).

The agent always drives the page from outside it (tool calls, never code in the page). This just lets the whole Pluckor unit live in a container while the agent runs elsewhere.

Two extra management tools let an agent recover on its own instead of getting stuck:

| Tool | What it does | Returns | |------|--------------|---------| | status | Daemon health: proxy vs daemon version, whether the daemon is outdated, browser connected, control reachable | { proxyVersion, controlReachable, daemon, outdated } | | restart | Stop the daemon — even a stale-pid or outdated one — start a fresh one, and reconnect | { restarted, daemon } |

If a browser tool fails with NO_BROWSER / NOT_CONNECTED / CONNECTION_LOST / a timeout, the error tells the agent to call restart and retry. A dropped control socket reconnects on its own; restart is for a daemon that is stale, wedged, or an older version than the one you just installed. (Because the browser is shared, restart bounces it for every session.)


How it works

agent   ── MCP/stdio ──▶  plk mcp (proxy)  ─┐
program ── NDJSON/argv ─▶  plk call | pipe ─┤  WebSocket (control)
                                            ▼
                      plk daemon ──▶ Chrome for Testing + Pluckor extension
                          │                      │
                          │  WebSocket           │ content script (reads, no CDP)
                          └────────────────────▶ │ chrome.debugger (run_js/click/type)
                                                 ▼
                                            the live page

The caller invokes a tool → its front end (the MCP proxy, or plk call/plk pipe) forwards it to the daemon → the daemon drives the extension → the extension acts on the page and sends the result back the same way. The two front ends share one tool registry and one validation path, so they can't drift. Everything binds to 127.0.0.1 only; the browser is never exposed off-box.

Neither front end queues: the daemon handles each control call independently and the extension dispatches per command, so many tabs can be working at once — whether that's several agents on their own plk mcp, or one plk pipe session driving ten tabs in parallel.


Example: scrape a Cloudflare-protected listing

navigate           { "url": "https://www.g2.com/categories/mortgage-crm" }
wait_for_selector  { "selector": ".product-card" }
run_js             { "expression": "Array.from(document.querySelectorAll('.product-card')).map(c => c.querySelector('a[href*=\"/products/\"]')?.textContent.trim())" }

→ the agent gets back a JSON array of product names. (The repo's examples/g2-extract.ts shows a full list → detail-page pipeline pulling ratings + review counts across multiple pages in one browser session.)


Configuration

| Env var | Default | Purpose | |---------|---------|---------| | PLUCKOR_HOME | ~/.pluckor-bridge | Persistent Chrome profile + Chrome for Testing download | | PLUCKOR_EXTENSION_DIST | (bundled) | Override the extension directory (advanced) | | PLUCKOR_NO_FEEDBACK | (unset) | Set to 1 to remove the optional agent-feedback request (below) |

The extension ships inside this package (it's not a separate install), and the persistent profile keeps cookies/logins across runs.

Agent feedback (optional, on by default)

Pluckor is driven by agents, so the ones best placed to notice that a tool is buggy, confusingly named, or missing something are the agents using it — and they have no way to tell us. At startup the MCP server therefore invites the agent to open an issue at github.com/pluckor/pluckor-feedbacks when it hits a bug, an awkward edge, a docs gap, or wishes a capability existed.

It's framed as a request, never a requirement, and explicitly ranked below the user's actual task; it also tells the agent not to put credentials, personal data, or scraped page content into an issue. Set PLUCKOR_NO_FEEDBACK=1 and the request is omitted entirely — it's sent as MCP server instructions, so the opt-out is real rather than advisory.


Notes & limits

  • First run downloads Chrome for Testing; later runs reuse the cache.
  • The browser is real and visible — a window opens. Reads leave no automation fingerprint; the chrome.debugger escalation is only for JS eval and trusted input.
  • One browser per machine. The daemon runs a single browser on a fixed local port; concurrent agent sessions share it (they don't each launch their own).
  • Interactive Cloudflare Turnstile (the checkbox) is a known boundary — a CDP-dispatched click can be detected, so treat it as needing a human.

License

MIT