mcp-scraper 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,8 +114,9 @@ env = { MCP_SCRAPER_API_KEY = "sk_live_your_key" }
114
114
  ### Browser-agent tools
115
115
 
116
116
  - `browser_open` — open a live cloud browser session. Returns a `session_id`, a human `watch_url`, and the raw `live_view_url` when available.
117
- - `browser_screenshot` — capture a screenshot plus visible text and clickable element coordinates.
118
- - `browser_read` — read the current page text and elements without an image.
117
+ - `browser_screenshot` — capture a screenshot plus visible text and clickable element center coordinates and DOM bounds.
118
+ - `browser_read` — read the current page text and elements with center coordinates and DOM bounds, without an image.
119
+ - `browser_locate` — locate exact visible DOM elements or text ranges and return screenshot-pixel bounds.
119
120
  - `browser_goto`
120
121
  - `browser_click`
121
122
  - `browser_type`
@@ -125,9 +126,13 @@ env = { MCP_SCRAPER_API_KEY = "sk_live_your_key" }
125
126
  - `browser_replay_stop` — stop a replay. Returns the final `view_url` and `download_url`.
126
127
  - `browser_list_replays` — list replay videos for a session.
127
128
  - `browser_replay_download` — download and save the replay MP4 locally under `MCP_SCRAPER_OUTPUT_DIR/browser-replays`.
129
+ - `browser_replay_mark` — while recording, locate a DOM target and return a replay-timed annotation object.
130
+ - `browser_replay_annotate` — download a replay MP4, render timed boxes, circles, underlines, arrows, and labels using annotation objects from `browser_replay_mark` or exact bounds from `browser_locate`, and save a new annotated MP4 locally.
128
131
  - `browser_close`
129
132
  - `browser_list_sessions`
130
133
 
134
+ For accurate annotated videos, do not guess annotation times from a script. Start the replay, navigate until each target is visible and stable, call `browser_replay_mark` for each callout, then stop the replay and pass the returned annotations to `browser_replay_annotate` with the returned `source_width` and `source_height`.
135
+
131
136
  For US local SERP tools (`harvest_paa` and `search_serp`), keep `proxyMode` at the default `location` unless you are debugging. Location mode uses fresh residential proxy IDs across retries and treats CAPTCHA, proxy tunnel failure, and wrong-location evidence as retryable before returning.
132
137
 
133
138
  The MCPB bundle and `mcp-scraper-combined` expose both sections through one local MCP server. The split `mcp-scraper` entrypoint exposes only the web-intelligence tools, and the split `browser-agent` entrypoint exposes only the browser-agent tools.
@@ -17082,7 +17082,7 @@ var PACKAGE_VERSION;
17082
17082
  var init_version = __esm({
17083
17083
  "src/version.ts"() {
17084
17084
  "use strict";
17085
- PACKAGE_VERSION = "0.2.6";
17085
+ PACKAGE_VERSION = "0.2.7";
17086
17086
  }
17087
17087
  });
17088
17088
 
@@ -18351,18 +18351,26 @@ async function recordAction(input) {
18351
18351
  async function recordReplayStart(input) {
18352
18352
  const db = getDb();
18353
18353
  await db.execute({
18354
- sql: `INSERT INTO browser_agent_replays (replay_id, session_id, view_url, label)
18355
- VALUES (?, ?, ?, ?)`,
18356
- args: [input.replayId, input.sessionId, input.viewUrl, input.label]
18354
+ sql: `INSERT INTO browser_agent_replays (replay_id, session_id, view_url, label, started_at)
18355
+ VALUES (?, ?, ?, ?, ?)`,
18356
+ args: [input.replayId, input.sessionId, input.viewUrl, input.label, (/* @__PURE__ */ new Date()).toISOString()]
18357
18357
  });
18358
18358
  }
18359
18359
  async function recordReplayStop(replayId, viewUrl) {
18360
18360
  const db = getDb();
18361
18361
  await db.execute({
18362
- sql: `UPDATE browser_agent_replays SET stopped_at = datetime('now'), view_url = COALESCE(?, view_url) WHERE replay_id = ?`,
18363
- args: [viewUrl, replayId]
18362
+ sql: `UPDATE browser_agent_replays SET stopped_at = ?, view_url = COALESCE(?, view_url) WHERE replay_id = ?`,
18363
+ args: [(/* @__PURE__ */ new Date()).toISOString(), viewUrl, replayId]
18364
18364
  });
18365
18365
  }
18366
+ async function getActiveReplayRow(sessionId) {
18367
+ const db = getDb();
18368
+ const res = await db.execute({
18369
+ sql: `SELECT * FROM browser_agent_replays WHERE session_id = ? AND stopped_at IS NULL ORDER BY started_at DESC LIMIT 1`,
18370
+ args: [sessionId]
18371
+ });
18372
+ return res.rows[0] ?? null;
18373
+ }
18366
18374
  async function listReplayRows(sessionId) {
18367
18375
  const db = getDb();
18368
18376
  const res = await db.execute({
@@ -18482,7 +18490,13 @@ async function readPage(cdpWsUrl) {
18482
18490
  role: el2.getAttribute("role") || el2.tagName.toLowerCase(),
18483
18491
  name,
18484
18492
  x: Math.round(r.left + r.width / 2),
18485
- y: Math.round(r.top + r.height / 2)
18493
+ y: Math.round(r.top + r.height / 2),
18494
+ left: Math.round(r.left),
18495
+ top: Math.round(r.top),
18496
+ width: Math.round(r.width),
18497
+ height: Math.round(r.height),
18498
+ right: Math.round(r.right),
18499
+ bottom: Math.round(r.bottom)
18486
18500
  });
18487
18501
  }
18488
18502
  const text = (document.body?.innerText || "").replace(/\n{3,}/g, "\n\n").trim().slice(0, 6e3);
@@ -18492,13 +18506,168 @@ async function readPage(cdpWsUrl) {
18492
18506
  url,
18493
18507
  title,
18494
18508
  text: data.text,
18495
- elements: data.els.map((e, i) => ({ ref: i + 1, role: e.role, name: e.name, x: e.x, y: e.y }))
18509
+ elements: data.els.map((e, i) => ({
18510
+ ref: i + 1,
18511
+ role: e.role,
18512
+ name: e.name,
18513
+ x: e.x,
18514
+ y: e.y,
18515
+ left: e.left,
18516
+ top: e.top,
18517
+ width: e.width,
18518
+ height: e.height,
18519
+ right: e.right,
18520
+ bottom: e.bottom
18521
+ }))
18496
18522
  };
18497
18523
  } finally {
18498
18524
  await browser.close().catch(() => {
18499
18525
  });
18500
18526
  }
18501
18527
  }
18528
+ async function locatePageTargets(cdpWsUrl, targets) {
18529
+ const browser = await import_playwright4.chromium.connectOverCDP(cdpWsUrl);
18530
+ try {
18531
+ const context = browser.contexts()[0] ?? await browser.newContext();
18532
+ const page = context.pages()[0] ?? await context.newPage();
18533
+ const url = page.url();
18534
+ const title = await page.title().catch(() => "");
18535
+ await page.evaluate(() => {
18536
+ ;
18537
+ globalThis.__name = (target) => target;
18538
+ }).catch(() => {
18539
+ });
18540
+ const data = await page.evaluate((rawTargets) => {
18541
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
18542
+ const isVisibleRect = (r) => r.width >= 1 && r.height >= 1 && r.bottom >= 0 && r.right >= 0 && r.top <= window.innerHeight && r.left <= window.innerWidth;
18543
+ const roleOf = (el2) => el2.getAttribute("role") || el2.tagName.toLowerCase();
18544
+ const nameOf = (el2) => {
18545
+ const e = el2;
18546
+ return normalize(
18547
+ e.getAttribute("aria-label") || e.placeholder || e.innerText || e.getAttribute("value") || e.getAttribute("title") || ""
18548
+ ).slice(0, 160);
18549
+ };
18550
+ const textOf = (el2) => normalize(el2.innerText || el2.textContent || "").slice(0, 500);
18551
+ const unionRects = (rects) => {
18552
+ const visible = rects.filter(isVisibleRect);
18553
+ if (!visible.length) return null;
18554
+ const left = Math.min(...visible.map((r) => r.left));
18555
+ const top = Math.min(...visible.map((r) => r.top));
18556
+ const right = Math.max(...visible.map((r) => r.right));
18557
+ const bottom = Math.max(...visible.map((r) => r.bottom));
18558
+ return {
18559
+ left,
18560
+ top,
18561
+ right,
18562
+ bottom,
18563
+ width: right - left,
18564
+ height: bottom - top,
18565
+ x: left,
18566
+ y: top,
18567
+ toJSON: () => ({})
18568
+ };
18569
+ };
18570
+ const elementPayload = (el2, rect) => ({
18571
+ role: roleOf(el2),
18572
+ tag: el2.tagName.toLowerCase(),
18573
+ name: nameOf(el2),
18574
+ text: textOf(el2),
18575
+ x: Math.round(rect.left + rect.width / 2),
18576
+ y: Math.round(rect.top + rect.height / 2),
18577
+ left: Math.round(rect.left),
18578
+ top: Math.round(rect.top),
18579
+ width: Math.round(rect.width),
18580
+ height: Math.round(rect.height),
18581
+ right: Math.round(rect.right),
18582
+ bottom: Math.round(rect.bottom)
18583
+ });
18584
+ const selectorMatches = (selector) => {
18585
+ const nodes = Array.from(document.querySelectorAll(selector));
18586
+ return nodes.map((el2) => ({ el: el2, rect: el2.getBoundingClientRect() })).filter((item) => isVisibleRect(item.rect));
18587
+ };
18588
+ const textMatches = (needle, match) => {
18589
+ const wanted = normalize(needle);
18590
+ const wantedLower = wanted.toLowerCase();
18591
+ if (!wantedLower) return [];
18592
+ const matches = [];
18593
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
18594
+ let node = walker.nextNode();
18595
+ while (node) {
18596
+ const raw = node.textContent || "";
18597
+ const normalizedRaw = normalize(raw);
18598
+ const rawLower = raw.toLowerCase();
18599
+ const normalizedLower = normalizedRaw.toLowerCase();
18600
+ const ok = match === "exact" ? normalizedLower === wantedLower : normalizedLower.includes(wantedLower);
18601
+ if (ok) {
18602
+ const start = match === "exact" ? 0 : rawLower.indexOf(wantedLower);
18603
+ const parent = node.parentElement;
18604
+ if (parent && start >= 0) {
18605
+ try {
18606
+ const range = document.createRange();
18607
+ range.setStart(node, Math.min(start, raw.length));
18608
+ range.setEnd(node, Math.min(raw.length, start + needle.length));
18609
+ const rect = unionRects(Array.from(range.getClientRects()));
18610
+ range.detach();
18611
+ if (rect) matches.push({ el: parent, rect });
18612
+ } catch {
18613
+ const rect = parent.getBoundingClientRect();
18614
+ if (isVisibleRect(rect)) matches.push({ el: parent, rect });
18615
+ }
18616
+ }
18617
+ }
18618
+ node = walker.nextNode();
18619
+ }
18620
+ if (matches.length) return matches;
18621
+ const fallback = Array.from(document.querySelectorAll("body *")).map((el2) => ({ el: el2, rect: el2.getBoundingClientRect(), text: textOf(el2) })).filter((item) => isVisibleRect(item.rect)).filter((item) => {
18622
+ const value = item.text.toLowerCase();
18623
+ return match === "exact" ? value === wantedLower : value.includes(wantedLower);
18624
+ }).sort((a, b) => a.rect.width * a.rect.height - b.rect.width * b.rect.height);
18625
+ return fallback.map(({ el: el2, rect }) => ({ el: el2, rect }));
18626
+ };
18627
+ const viewport = {
18628
+ width: window.innerWidth,
18629
+ height: window.innerHeight,
18630
+ devicePixelRatio: window.devicePixelRatio || 1
18631
+ };
18632
+ const results = rawTargets.map((target) => {
18633
+ const index = Math.max(0, Math.floor(target.index ?? 0));
18634
+ const match = target.match === "exact" ? "exact" : "contains";
18635
+ try {
18636
+ const selector = target.selector?.trim();
18637
+ const text = target.text?.trim();
18638
+ const candidates = selector ? selectorMatches(selector) : text ? textMatches(text, match) : [];
18639
+ const selected = candidates[index];
18640
+ return {
18641
+ name: target.name ?? null,
18642
+ selector: selector || null,
18643
+ text: text || null,
18644
+ match,
18645
+ index,
18646
+ found: Boolean(selected),
18647
+ element: selected ? elementPayload(selected.el, selected.rect) : null,
18648
+ ...selected ? {} : { error: "target not found in current viewport" }
18649
+ };
18650
+ } catch (err) {
18651
+ return {
18652
+ name: target.name ?? null,
18653
+ selector: target.selector ?? null,
18654
+ text: target.text ?? null,
18655
+ match,
18656
+ index,
18657
+ found: false,
18658
+ element: null,
18659
+ error: err instanceof Error ? err.message : String(err)
18660
+ };
18661
+ }
18662
+ });
18663
+ return { viewport, targets: results };
18664
+ }, targets);
18665
+ return { url, title, viewport: data.viewport, targets: data.targets };
18666
+ } finally {
18667
+ await browser.close().catch(() => {
18668
+ });
18669
+ }
18670
+ }
18502
18671
  async function replayStart(runtimeSessionId) {
18503
18672
  const k = client();
18504
18673
  const res = await k.browsers.replays.start(runtimeSessionId);
@@ -18561,6 +18730,22 @@ function failure(err) {
18561
18730
  const msg = err instanceof Error ? err.message : String(err);
18562
18731
  return { error: sanitizeVendorName(msg) };
18563
18732
  }
18733
+ function parseAgentDate(value) {
18734
+ if (!value) return null;
18735
+ const parsed = Date.parse(value);
18736
+ if (Number.isFinite(parsed)) return parsed;
18737
+ const legacyParsed = Date.parse(`${value.replace(" ", "T")}Z`);
18738
+ return Number.isFinite(legacyParsed) ? legacyParsed : null;
18739
+ }
18740
+ function replayClock(row) {
18741
+ if (!row) return null;
18742
+ const startedAtMs = parseAgentDate(row.started_at);
18743
+ return {
18744
+ replay_id: row.replay_id,
18745
+ started_at: row.started_at,
18746
+ replay_elapsed_seconds: startedAtMs == null ? null : Math.max(0, (Date.now() - startedAtMs) / 1e3)
18747
+ };
18748
+ }
18564
18749
  function replayDownloadUrl(sessionId, replayId) {
18565
18750
  return `/agent/sessions/${encodeURIComponent(sessionId)}/replays/${encodeURIComponent(replayId)}/download`;
18566
18751
  }
@@ -18671,13 +18856,15 @@ function buildBrowserAgentRoutes() {
18671
18856
  }
18672
18857
  await charge(row.id, user.id, t0);
18673
18858
  await recordAction({ sessionId: row.id, type: "screenshot", params: null, ok: true });
18859
+ const activeReplay = await getActiveReplayRow(row.id);
18674
18860
  return c.json({
18675
18861
  image_base64: shot.base64,
18676
18862
  mime_type: shot.mimeType,
18677
18863
  url: page?.url ?? null,
18678
18864
  title: page?.title ?? null,
18679
18865
  elements: page?.elements ?? [],
18680
- text: page?.text ?? null
18866
+ text: page?.text ?? null,
18867
+ replay: replayClock(activeReplay)
18681
18868
  });
18682
18869
  } catch (err) {
18683
18870
  await charge(row.id, user.id, t0);
@@ -18694,7 +18881,8 @@ function buildBrowserAgentRoutes() {
18694
18881
  const page = await readPage(row.cdp_ws_url);
18695
18882
  await charge(row.id, user.id, t0);
18696
18883
  await recordAction({ sessionId: row.id, type: "read", params: null, ok: true });
18697
- return c.json(page);
18884
+ const activeReplay = await getActiveReplayRow(row.id);
18885
+ return c.json({ ...page, replay: replayClock(activeReplay) });
18698
18886
  } catch (err) {
18699
18887
  await charge(row.id, user.id, t0);
18700
18888
  return c.json(failure(err), 502);
@@ -18723,6 +18911,27 @@ function buildBrowserAgentRoutes() {
18723
18911
  return c.json(failure(err), 502);
18724
18912
  }
18725
18913
  });
18914
+ app2.post("/sessions/:id/locate", async (c) => {
18915
+ const user = c.get("user");
18916
+ const row = await loadOpenSession(c.req.param("id"), user.id);
18917
+ if (!row) return c.json({ error: "not found" }, 404);
18918
+ const body = await c.req.json().catch(() => ({}));
18919
+ const rawTargets = Array.isArray(body.targets) ? body.targets : [];
18920
+ const targets = rawTargets.filter((value) => value && typeof value === "object").slice(0, 20).map((value) => value);
18921
+ if (!targets.length) return c.json({ error: "targets is required" }, 400);
18922
+ const t0 = Date.now();
18923
+ try {
18924
+ const result = await locatePageTargets(row.cdp_ws_url, targets);
18925
+ await charge(row.id, user.id, t0);
18926
+ await recordAction({ sessionId: row.id, type: "locate", params: { targets: targets.length }, ok: true });
18927
+ const activeReplay = await getActiveReplayRow(row.id);
18928
+ return c.json({ ...result, replay: replayClock(activeReplay) });
18929
+ } catch (err) {
18930
+ await charge(row.id, user.id, t0);
18931
+ await recordAction({ sessionId: row.id, type: "locate", params: { targets: targets.length }, ok: false, error: String(err) });
18932
+ return c.json(failure(err), 502);
18933
+ }
18934
+ });
18726
18935
  app2.post("/sessions/:id/type", async (c) => {
18727
18936
  const user = c.get("user");
18728
18937
  const row = await loadOpenSession(c.req.param("id"), user.id);