mcp-scraper 0.2.14 → 0.2.16

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.
Files changed (33) hide show
  1. package/README.md +6 -5
  2. package/dist/bin/api-server.cjs +321 -29
  3. package/dist/bin/api-server.cjs.map +1 -1
  4. package/dist/bin/api-server.js +1 -1
  5. package/dist/bin/browser-agent-stdio-server.cjs +1 -1
  6. package/dist/bin/browser-agent-stdio-server.cjs.map +1 -1
  7. package/dist/bin/browser-agent-stdio-server.js +2 -2
  8. package/dist/bin/mcp-scraper-cli.cjs +1 -1
  9. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  10. package/dist/bin/mcp-scraper-cli.js +1 -1
  11. package/dist/bin/mcp-scraper-combined-stdio-server.cjs +95 -6
  12. package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +1 -1
  13. package/dist/bin/mcp-scraper-combined-stdio-server.js +3 -3
  14. package/dist/bin/mcp-scraper-install.cjs +4 -4
  15. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  16. package/dist/bin/mcp-scraper-install.js +4 -4
  17. package/dist/bin/mcp-scraper-install.js.map +1 -1
  18. package/dist/bin/mcp-stdio-server.cjs +95 -6
  19. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  20. package/dist/bin/mcp-stdio-server.js +2 -2
  21. package/dist/{chunk-ZV2XXYR7.js → chunk-ATJAINML.js} +2 -2
  22. package/dist/{chunk-IWQTNY2E.js → chunk-FMC3V54I.js} +96 -7
  23. package/dist/chunk-FMC3V54I.js.map +1 -0
  24. package/dist/chunk-KE4JZDLV.js +7 -0
  25. package/dist/chunk-KE4JZDLV.js.map +1 -0
  26. package/dist/{server-7QP6HQJJ.js → server-KSEQLZNP.js} +221 -24
  27. package/dist/server-KSEQLZNP.js.map +1 -0
  28. package/package.json +1 -1
  29. package/dist/chunk-IWQTNY2E.js.map +0 -1
  30. package/dist/chunk-ROCXJOVY.js +0 -7
  31. package/dist/chunk-ROCXJOVY.js.map +0 -1
  32. package/dist/server-7QP6HQJJ.js.map +0 -1
  33. /package/dist/{chunk-ZV2XXYR7.js.map → chunk-ATJAINML.js.map} +0 -0
package/README.md CHANGED
@@ -8,7 +8,7 @@ Use the MCPB Desktop Extension for the branded Claude Desktop install, or use th
8
8
 
9
9
  MCP Scraper ships three local stdio entrypoints plus human-facing helper CLIs:
10
10
 
11
- - `mcp-scraper` — live web intelligence, SERP, PAA, site extraction, YouTube, Facebook, Maps, directory, rank tracker blueprint, and credit tools.
11
+ - `mcp-scraper` — live web intelligence, SERP, PAA, site extraction, YouTube, Facebook ads and organic video transcripts, Maps, directory, rank tracker blueprint, and credit tools.
12
12
  - `browser-agent` — an agent-controlled live cloud browser with screenshots, clicks, typing, scrolling, live watch URLs, replay links, and MP4 replay download.
13
13
  - `mcp-scraper-combined` — one local MCP server that exposes both tool sets. This is the entrypoint used by the MCPB Desktop Extension.
14
14
  - `mcp-scraper-install` — a human-facing terminal installer card with the branded ASCII intro and copyable install commands. This command is safe to print because it is not an MCP stdio server.
@@ -81,7 +81,7 @@ Build the branded one-click bundle:
81
81
  npm run build:mcpb
82
82
  ```
83
83
 
84
- The generated bundle is written to `build/mcpb/mcp-scraper-<version>.mcpb` and copied to `public/downloads/` for the hosted download. The current public bundle is `https://mcpscraper.dev/downloads/mcp-scraper.mcpb` (`0.2.13`, SHA-256 `93c3c35b6a3315a1b9319761891cf0e805465b1ab19efa0252c7b8f2d9383030`). Install it by opening or dragging it into Claude Desktop. Claude displays the `MCP Scraper` install card, icon, and API-key configuration field from the bundle manifest.
84
+ The generated bundle is written to `build/mcpb/mcp-scraper-<version>.mcpb` and copied to `public/downloads/` for the hosted download. The current public bundle is `https://mcpscraper.dev/downloads/mcp-scraper.mcpb` (`0.2.16`, SHA-256 `a581f0ddb12d3cebd03d90dfb657cc9127a1f7ef995fb0934d5428faf8aec58b`). Install it by opening or dragging it into Claude Desktop. Claude displays the `MCP Scraper` install card, icon, and API-key configuration field from the bundle manifest.
85
85
 
86
86
  The MCPB install exposes the same web-intelligence tools as `mcp-scraper` plus all `browser_*` tools from `browser-agent` through one server.
87
87
 
@@ -154,7 +154,8 @@ env = { MCP_SCRAPER_API_KEY = "sk_live_your_key" }
154
154
  - `youtube_transcribe`
155
155
  - `facebook_ad_search`
156
156
  - `facebook_page_intel`
157
- - `facebook_ad_transcribe`
157
+ - `facebook_ad_transcribe` — transcribe a direct Facebook ad video URL returned by `facebook_page_intel`.
158
+ - `facebook_video_transcribe` — transcribe an organic Facebook reel, video, watch, post, or share URL, including `fb.watch` links. The tool renders the page, extracts the best matching public Facebook CDN MP4 URL, then returns transcript text, timestamped chunks, selected quality, video metadata, and the extracted MP4 URL for follow-up download.
158
159
  - `maps_search` — search Google Maps for multiple business/profile candidates. Use for GMB/GBP prospect lists, competitors, categories, and anything needing more than the Google 3-pack. In default `proxyMode: "location"`, retryable failures rotate to a new residential proxy and new browser session for up to 5 attempts. `maxResults` defaults to 10 and is capped at 50.
159
160
  - `maps_place_intel` — hydrate one known/named Google Maps business with profile details and optional reviews. Use after `maps_search` when a selected candidate needs full details.
160
161
  - `directory_workflow` — build city-by-city directory/prospecting datasets from Census place selection plus Google Maps searches. Use it for requests like "all cities over 100k population in Tennessee, then get 20 roofers from Maps." In default `proxyMode: "location"`, each city search rotates retryable failures to a new residential proxy and new browser session for up to 5 attempts. The saved CSV includes `source_location`, `result_position`, `business_name`, `review_stars`, `review_count`, `category`, `address`, `phone`, `hours_status`, `website_url`, `directions_url`, `place_url`, `cid`, `cid_decimal`, Census population, and ZIP groups.
@@ -189,9 +190,9 @@ For Google Maps tools (`maps_search` and `directory_workflow`), keep `proxyMode`
189
190
 
190
191
  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.
191
192
 
192
- Chaining and planning tools (`maps_search`, `directory_workflow`, `rank_tracker_blueprint`, `map_site_urls`, `youtube_harvest`, `facebook_ad_search`, `facebook_page_intel`) advertise an `outputSchema` and return `structuredContent` with the IDs, URLs, CSV paths, or blueprint fields needed by the next step. All tools carry MCP annotations (`readOnlyHint: true`; live-web tools use `openWorldHint: true`, while local planning and credit tools are closed-world/idempotent).
193
+ Chaining and planning tools (`maps_search`, `directory_workflow`, `rank_tracker_blueprint`, `map_site_urls`, `youtube_harvest`, `facebook_ad_search`, `facebook_page_intel`, `facebook_video_transcribe`) advertise an `outputSchema` and return `structuredContent` with the IDs, URLs, CSV paths, transcripts, or blueprint fields needed by the next step. All tools carry MCP annotations (`readOnlyHint: true`; live-web tools use `openWorldHint: true`, while local planning and credit tools are closed-world/idempotent).
193
194
 
194
- The hosted MCP endpoint at `https://mcpscraper.dev/mcp` exposes the 15 web-intelligence tools plus `capture_serp_snapshot` and `capture_serp_page_snapshots` (17 total). Browser-agent tools are local stdio tools backed by the REST API under `https://mcpscraper.dev/agent/*`.
195
+ The hosted MCP endpoint at `https://mcpscraper.dev/mcp` exposes the 16 web-intelligence tools plus `capture_serp_snapshot` and `capture_serp_page_snapshots` (18 total). Browser-agent tools are local stdio tools backed by the REST API under `https://mcpscraper.dev/agent/*`.
195
196
 
196
197
  ## Resources
197
198
 
@@ -11618,6 +11618,132 @@ var init_FacebookAdGraphql = __esm({
11618
11618
  }
11619
11619
  });
11620
11620
 
11621
+ // src/extractor/FacebookOrganicVideoExtractor.ts
11622
+ function htmlDecode(value) {
11623
+ return value.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#039;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
11624
+ }
11625
+ function decodeEscapedValue(raw) {
11626
+ let out = raw;
11627
+ try {
11628
+ out = JSON.parse(`"${raw.replace(/"/g, '\\"')}"`);
11629
+ } catch {
11630
+ out = raw.replace(/\\u0025/g, "%").replace(/\\u0026/g, "&").replace(/\\u003D/g, "=").replace(/\\u003d/g, "=").replace(/\\"/g, '"').replace(/\\\//g, "/");
11631
+ }
11632
+ return htmlDecode(out);
11633
+ }
11634
+ function facebookVideoIdFromUrl(url) {
11635
+ try {
11636
+ const parsed = new URL(url);
11637
+ const path6 = parsed.pathname;
11638
+ const direct = path6.match(/\/(?:reel|videos|watch)\/(?:[^/]+\/)?(\d{8,})/i);
11639
+ if (direct) return direct[1];
11640
+ const queryVideoId = parsed.searchParams.get("v") ?? parsed.searchParams.get("video_id");
11641
+ if (queryVideoId && /^\d{8,}$/.test(queryVideoId)) return queryVideoId;
11642
+ const numericTail = path6.match(/\/(\d{8,})\/?$/);
11643
+ if (numericTail) return numericTail[1];
11644
+ } catch {
11645
+ }
11646
+ return null;
11647
+ }
11648
+ function parseEfg(url) {
11649
+ try {
11650
+ const efg = new URL(url).searchParams.get("efg");
11651
+ if (!efg) return null;
11652
+ return JSON.parse(Buffer.from(efg, "base64").toString("utf8"));
11653
+ } catch {
11654
+ return null;
11655
+ }
11656
+ }
11657
+ function manifestDurationSec(htmlWindow) {
11658
+ const decoded = decodeEscapedValue(htmlWindow);
11659
+ const match = decoded.match(/mediaPresentationDuration="PT([\d.]+)S"/);
11660
+ return match ? Number(match[1]) : null;
11661
+ }
11662
+ function metadataContent(html, key) {
11663
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11664
+ const re = new RegExp(`<meta\\s+[^>]*(?:property|name)=["']${escaped}["'][^>]*content=["']([^"']+)["'][^>]*>`, "i");
11665
+ const m = html.match(re);
11666
+ return m ? htmlDecode(m[1]).trim() : null;
11667
+ }
11668
+ function ownerNameFromHtml(html) {
11669
+ const ogTitle = metadataContent(html, "og:title");
11670
+ const pipe = ogTitle?.split("|").map((s) => s.trim()).filter(Boolean);
11671
+ if (pipe && pipe.length > 1) return pipe[pipe.length - 1] ?? null;
11672
+ return null;
11673
+ }
11674
+ function extractFacebookOrganicVideo(html, pageUrl, sourceUrl = pageUrl, quality = "best") {
11675
+ const videoId = facebookVideoIdFromUrl(pageUrl) ?? facebookVideoIdFromUrl(sourceUrl);
11676
+ const manifestIndex = videoId ? html.indexOf(`dash_mpd_debug.mpd?v=${videoId}`) : -1;
11677
+ const searchWindow = manifestIndex >= 0 ? html.slice(Math.max(0, manifestIndex - 5e4), Math.min(html.length, manifestIndex + 5e4)) : html;
11678
+ const targetDuration = manifestDurationSec(searchWindow);
11679
+ const candidates = [];
11680
+ const seen = /* @__PURE__ */ new Set();
11681
+ const re = /browser_native_(sd|hd)_url\\?"\s*:\s*\\?"([^"<]+?)\\?"/g;
11682
+ let match;
11683
+ while ((match = re.exec(searchWindow)) !== null) {
11684
+ const url = decodeEscapedValue(match[2]);
11685
+ if (!url || seen.has(url)) continue;
11686
+ seen.add(url);
11687
+ const parsed = parseEfg(url);
11688
+ let bitrate = null;
11689
+ try {
11690
+ const value = new URL(url).searchParams.get("bitrate");
11691
+ bitrate = value ? Number(value) : null;
11692
+ } catch {
11693
+ }
11694
+ candidates.push({
11695
+ url,
11696
+ quality: match[1],
11697
+ bitrate: Number.isFinite(bitrate) ? bitrate : null,
11698
+ durationSec: typeof parsed?.duration_s === "number" ? parsed.duration_s : null,
11699
+ assetId: typeof parsed?.xpv_asset_id === "number" ? parsed.xpv_asset_id : null,
11700
+ vencodeTag: typeof parsed?.vencode_tag === "string" ? parsed.vencode_tag : null
11701
+ });
11702
+ }
11703
+ const targetCandidates = candidates.filter((candidate) => {
11704
+ if (!targetDuration || candidate.durationSec == null) return true;
11705
+ return Math.abs(candidate.durationSec - targetDuration) <= 2;
11706
+ });
11707
+ const selectable = targetCandidates.length ? targetCandidates : candidates;
11708
+ const selected = [...selectable].filter((candidate) => quality === "best" || candidate.quality === quality).sort((a, b) => {
11709
+ const aq = a.quality === "hd" ? 1 : 0;
11710
+ const bq = b.quality === "hd" ? 1 : 0;
11711
+ if (quality === "best" && aq !== bq) return bq - aq;
11712
+ return (b.bitrate ?? 0) - (a.bitrate ?? 0);
11713
+ })[0];
11714
+ if (!selected) {
11715
+ throw new Error("No progressive Facebook video URL was found in the rendered page state");
11716
+ }
11717
+ return {
11718
+ sourceUrl,
11719
+ pageUrl,
11720
+ videoId,
11721
+ title: metadataContent(html, "og:title") ?? metadataContent(html, "twitter:title"),
11722
+ description: metadataContent(html, "og:description") ?? metadataContent(html, "description") ?? metadataContent(html, "twitter:description"),
11723
+ ownerName: ownerNameFromHtml(html),
11724
+ videoUrl: selected.url,
11725
+ selectedQuality: selected.quality,
11726
+ bitrate: selected.bitrate,
11727
+ durationSec: targetDuration ?? selected.durationSec,
11728
+ assetId: selected.assetId,
11729
+ vencodeTag: selected.vencodeTag,
11730
+ candidates: selectable,
11731
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString()
11732
+ };
11733
+ }
11734
+ async function extractFacebookOrganicVideoFromPage(page, sourceUrl, quality = "best") {
11735
+ await page.waitForLoadState("domcontentloaded", { timeout: 15e3 }).catch(() => {
11736
+ });
11737
+ await page.waitForTimeout(1500);
11738
+ const html = await page.content();
11739
+ return extractFacebookOrganicVideo(html, page.url(), sourceUrl, quality);
11740
+ }
11741
+ var init_FacebookOrganicVideoExtractor = __esm({
11742
+ "src/extractor/FacebookOrganicVideoExtractor.ts"() {
11743
+ "use strict";
11744
+ }
11745
+ });
11746
+
11621
11747
  // src/locations.ts
11622
11748
  var LOCATIONS;
11623
11749
  var init_locations = __esm({
@@ -12161,6 +12287,39 @@ var init_kernel_proxy_resolver = __esm({
12161
12287
  function invalidRequest(message) {
12162
12288
  return { error_code: "invalid_request", message };
12163
12289
  }
12290
+ function isAllowedFacebookOrganicUrl(url) {
12291
+ const hostname = url.hostname.toLowerCase().replace(/^www\./, "").replace(/^m\./, "");
12292
+ return hostname === "facebook.com" || hostname === "fb.watch";
12293
+ }
12294
+ function facebookTranscriptMarkdown(title, text, chunks, durationMs) {
12295
+ const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
12296
+ const lines = [title, "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
12297
+ if (chunks.length) {
12298
+ lines.push("## Timestamped Segments", "");
12299
+ for (const ch of chunks) {
12300
+ lines.push(`**[${fmtTs2(ch.timestamp[0])} -> ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
12301
+ }
12302
+ }
12303
+ return lines.join("\n");
12304
+ }
12305
+ async function transcribeFacebookVideoUrl(videoUrl, markdownTitle = "# Facebook Video Transcript") {
12306
+ const startMs = Date.now();
12307
+ const result = await import_client3.fal.subscribe("fal-ai/wizper", {
12308
+ input: { audio_url: videoUrl, task: "transcribe", language: "en" },
12309
+ logs: false,
12310
+ pollInterval: 3e3
12311
+ });
12312
+ const data = result.data;
12313
+ const text = data.text ?? "";
12314
+ const chunks = data.chunks ?? [];
12315
+ const durationMs = Date.now() - startMs;
12316
+ return {
12317
+ text,
12318
+ chunks,
12319
+ durationMs,
12320
+ markdown: facebookTranscriptMarkdown(markdownTitle, text, chunks, durationMs)
12321
+ };
12322
+ }
12164
12323
  async function detectSoftBlock(driver) {
12165
12324
  const page = driver.getPage();
12166
12325
  const bodyText = await page.evaluate(() => document.body?.innerText ?? "").catch(() => "");
@@ -12190,7 +12349,7 @@ async function kernelLaunchOptsResidential() {
12190
12349
  }
12191
12350
  return { headless: true, kernelApiKey: browserServiceApiKey(), kernelProxyId: proxyId, viewport: { width: 1280, height: 900 }, locale: "en-US" };
12192
12351
  }
12193
- var import_hono4, import_zod15, import_client3, FacebookAdBodySchema, FacebookPageIntelBodySchema, FacebookTranscribeBodySchema, FacebookSearchBodySchema, FacebookMediaBodySchema, facebookAdApp, ALLOWED_MEDIA_HOSTS;
12352
+ var import_hono4, import_zod15, import_client3, FacebookAdBodySchema, FacebookPageIntelBodySchema, FacebookTranscribeBodySchema, FacebookVideoTranscribeBodySchema, FacebookSearchBodySchema, FacebookMediaBodySchema, facebookAdApp, ALLOWED_MEDIA_HOSTS;
12194
12353
  var init_facebook_ad_routes = __esm({
12195
12354
  "src/api/facebook-ad-routes.ts"() {
12196
12355
  "use strict";
@@ -12202,6 +12361,7 @@ var init_facebook_ad_routes = __esm({
12202
12361
  init_BrowserDriver();
12203
12362
  init_FacebookAdExtractor();
12204
12363
  init_FacebookAdGraphql();
12364
+ init_FacebookOrganicVideoExtractor();
12205
12365
  init_kernel_proxy_resolver();
12206
12366
  import_client3 = require("@fal-ai/client");
12207
12367
  init_api_auth();
@@ -12224,6 +12384,10 @@ var init_facebook_ad_routes = __esm({
12224
12384
  FacebookTranscribeBodySchema = import_zod15.z.object({
12225
12385
  videoUrl: import_zod15.z.string().trim().min(1, "videoUrl is required")
12226
12386
  });
12387
+ FacebookVideoTranscribeBodySchema = import_zod15.z.object({
12388
+ url: import_zod15.z.string().trim().min(1, "url is required"),
12389
+ quality: import_zod15.z.enum(["best", "hd", "sd"]).default("best")
12390
+ });
12227
12391
  FacebookSearchBodySchema = import_zod15.z.object({
12228
12392
  query: import_zod15.z.string().trim().min(1, "query is required"),
12229
12393
  country: import_zod15.z.string().trim().toUpperCase().optional(),
@@ -12347,26 +12511,9 @@ var init_facebook_ad_routes = __esm({
12347
12511
  const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE, videoUrl);
12348
12512
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_transcribe), 402);
12349
12513
  debited = true;
12350
- const startMs = Date.now();
12351
- const result = await import_client3.fal.subscribe("fal-ai/wizper", {
12352
- input: { audio_url: videoUrl, task: "transcribe", language: "en" },
12353
- logs: false,
12354
- pollInterval: 3e3
12355
- });
12356
- const data = result.data;
12357
- const text = data.text ?? "";
12358
- const chunks = data.chunks ?? [];
12359
- const durationMs = Date.now() - startMs;
12360
- const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
12361
- const lines = ["# Facebook Ad Transcript", "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
12362
- if (chunks.length) {
12363
- lines.push("## Timestamped Segments", "");
12364
- for (const ch of chunks) {
12365
- lines.push(`**[${fmtTs2(ch.timestamp[0])} \u2192 ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
12366
- }
12367
- }
12368
- await logRequestEvent({ userId: fbUser.id, source: "facebook_transcribe", status: "done", query: videoUrl, resultCount: chunks.length, result: { text, chunks, durationMs } });
12369
- return c.json({ text, chunks, durationMs, markdown: lines.join("\n") });
12514
+ const transcript = await transcribeFacebookVideoUrl(videoUrl, "# Facebook Ad Transcript");
12515
+ await logRequestEvent({ userId: fbUser.id, source: "facebook_transcribe", status: "done", query: videoUrl, resultCount: transcript.chunks.length, result: { text: transcript.text, chunks: transcript.chunks, durationMs: transcript.durationMs } });
12516
+ return c.json(transcript);
12370
12517
  } catch (err) {
12371
12518
  const msg = err instanceof Error ? err.message : String(err);
12372
12519
  if (debited) await creditMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE_REFUND, "failed call");
@@ -12376,6 +12523,62 @@ var init_facebook_ad_routes = __esm({
12376
12523
  await releaseConcurrencyGate(gate.lockId);
12377
12524
  }
12378
12525
  });
12526
+ facebookAdApp.post("/video-transcribe", createApiKeyAuth(), async (c) => {
12527
+ const raw = await c.req.json().catch(() => ({}));
12528
+ const parsed = FacebookVideoTranscribeBodySchema.safeParse(raw);
12529
+ if (!parsed.success) {
12530
+ return c.json(invalidRequest(parsed.error.issues[0]?.message ?? "Invalid request"), 400);
12531
+ }
12532
+ const body = parsed.data;
12533
+ const urlCheck = await validatePublicHttpUrl(body.url, { field: "url", requireHttps: false });
12534
+ if (urlCheck.error) {
12535
+ return c.json(invalidRequest(urlCheck.error), 400);
12536
+ }
12537
+ const sourceUrl = urlCheck.parsed;
12538
+ if (!isAllowedFacebookOrganicUrl(sourceUrl)) {
12539
+ return c.json(invalidRequest("url must be a Facebook organic video, reel, or share URL"), 400);
12540
+ }
12541
+ const fbUser = c.get("user");
12542
+ const gate = await acquireConcurrencyGate(fbUser, "facebook_video_transcribe", {
12543
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
12544
+ metadata: { url: sourceUrl.href }
12545
+ });
12546
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
12547
+ import_client3.fal.config({ credentials: process.env.FAL_KEY });
12548
+ const driver = new BrowserDriver();
12549
+ let debited = false;
12550
+ try {
12551
+ const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE, sourceUrl.href);
12552
+ if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_transcribe), 402);
12553
+ debited = true;
12554
+ await driver.launch(await kernelLaunchOptsResidential());
12555
+ await driver.navigateTo(sourceUrl.href);
12556
+ const page = driver.getPage();
12557
+ const video = await extractFacebookOrganicVideoFromPage(page, sourceUrl.href, body.quality);
12558
+ const transcript = await transcribeFacebookVideoUrl(video.videoUrl, "# Facebook Organic Video Transcript");
12559
+ const result = {
12560
+ ...video,
12561
+ videoDurationSec: video.durationSec,
12562
+ text: transcript.text,
12563
+ chunks: transcript.chunks,
12564
+ durationMs: transcript.durationMs,
12565
+ markdown: transcript.markdown
12566
+ };
12567
+ await logRequestEvent({ userId: fbUser.id, source: "facebook_video_transcribe", status: "done", query: sourceUrl.href, resultCount: transcript.chunks.length, result });
12568
+ return c.json(result);
12569
+ } catch (err) {
12570
+ const msg = err instanceof Error ? err.message : String(err);
12571
+ if (debited) await creditMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE_REFUND, "failed call");
12572
+ await logRequestEvent({ userId: fbUser.id, source: "facebook_video_transcribe", status: "failed", query: sourceUrl.href, error: msg });
12573
+ if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
12574
+ return c.json({ error: msg }, 503);
12575
+ }
12576
+ return c.json({ error: msg }, 500);
12577
+ } finally {
12578
+ await driver.close();
12579
+ await releaseConcurrencyGate(gate.lockId);
12580
+ }
12581
+ });
12379
12582
  facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
12380
12583
  const raw = await c.req.json().catch(() => ({}));
12381
12584
  const parsed = FacebookSearchBodySchema.safeParse(raw);
@@ -14153,7 +14356,8 @@ ${adBlocks}`,
14153
14356
  `
14154
14357
  ---
14155
14358
  \u{1F4A1} **Tips**
14156
- - Transcribe video ads: use \`facebook_ad_transcribe\` with the \`videoUrl\` above
14359
+ - Transcribe video ads: use \`facebook_ad_transcribe\` with the direct \`videoUrl\` above
14360
+ - Transcribe organic Facebook reels/posts: use \`facebook_video_transcribe\` with the public Facebook URL
14157
14361
  - Find other advertisers: use \`facebook_ad_search\``
14158
14362
  ].filter(Boolean).join("\n");
14159
14363
  return {
@@ -14599,10 +14803,66 @@ ${text}`,
14599
14803
  ${chunkRows}` : "",
14600
14804
  `
14601
14805
  ---
14602
- \u{1F4A1} Get more ads from this advertiser: use \`facebook_page_intel\``
14806
+ \u{1F4A1} Get more ads from this advertiser: use \`facebook_page_intel\`. For public Facebook reel/post URLs, use \`facebook_video_transcribe\`.`
14603
14807
  ].filter(Boolean).join("\n");
14604
14808
  return oneBlock(full);
14605
14809
  }
14810
+ function formatFacebookVideoTranscribe(raw, input) {
14811
+ const parsed = parseData(raw);
14812
+ if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
14813
+ const d = parsed.data;
14814
+ const text = d.text ?? "";
14815
+ const chunks = d.chunks ?? [];
14816
+ const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
14817
+ const durSec = d.durationMs ? (d.durationMs / 1e3).toFixed(0) : "\u2014";
14818
+ const videoDuration = typeof d.videoDurationSec === "number" ? `${Math.round(d.videoDurationSec)}s` : "\u2014";
14819
+ const label = d.videoId ? `Facebook Organic Video \`${d.videoId}\`` : "Facebook Organic Video";
14820
+ const chunkRows = chunks.slice(0, 50).map((c) => {
14821
+ const sec = Number.isFinite(c.timestamp[0]) ? Math.floor(c.timestamp[0]) : 0;
14822
+ const mm = String(Math.floor(sec / 60)).padStart(2, "0");
14823
+ const ss = String(sec % 60).padStart(2, "0");
14824
+ return `| ${mm}:${ss} | ${cell(truncate(c.text, 120))} |`;
14825
+ }).join("\n");
14826
+ const full = [
14827
+ `# ${label} Transcript`,
14828
+ d.ownerName ? `**Owner:** ${d.ownerName}` : "",
14829
+ `**Video duration:** ${videoDuration} \xB7 **Transcribed in:** ${durSec}s \xB7 **${wordCount} words**`,
14830
+ d.pageUrl ? `**Page URL:** ${d.pageUrl}` : `**Page URL:** ${input.url}`,
14831
+ d.videoUrl ? `**Extracted MP4:** \`${d.videoUrl}\`` : "",
14832
+ `
14833
+ ## Full Transcript
14834
+ ${text}`,
14835
+ chunks.length ? `
14836
+ ## Timestamped Chunks
14837
+ | Time | Text |
14838
+ |------|------|
14839
+ ${chunkRows}` : "",
14840
+ `
14841
+ ---
14842
+ \u{1F4A1} Use \`videoUrl\` as the extracted MP4 for download or follow-up processing. Use \`facebook_ad_transcribe\` only for Ad Library videoUrl values.`
14843
+ ].filter(Boolean).join("\n");
14844
+ return {
14845
+ ...oneBlock(full),
14846
+ structuredContent: {
14847
+ sourceUrl: d.sourceUrl ?? input.url,
14848
+ pageUrl: d.pageUrl ?? input.url,
14849
+ videoId: d.videoId ?? null,
14850
+ ownerName: d.ownerName ?? null,
14851
+ selectedQuality: d.selectedQuality ?? input.quality ?? "best",
14852
+ bitrate: typeof d.bitrate === "number" ? d.bitrate : null,
14853
+ videoDurationSec: typeof d.videoDurationSec === "number" ? d.videoDurationSec : null,
14854
+ videoUrl: d.videoUrl ?? "",
14855
+ wordCount,
14856
+ chunkCount: chunks.length,
14857
+ transcriptText: text,
14858
+ chunks: chunks.map((c) => ({
14859
+ startSec: Number.isFinite(c.timestamp[0]) ? c.timestamp[0] : 0,
14860
+ endSec: Number.isFinite(c.timestamp[1]) ? c.timestamp[1] : 0,
14861
+ text: c.text
14862
+ }))
14863
+ }
14864
+ };
14865
+ }
14606
14866
  var import_node_fs3, import_node_os3, import_node_path5, reportSavingEnabled;
14607
14867
  var init_mcp_response_formatter = __esm({
14608
14868
  "src/mcp/mcp-response-formatter.ts"() {
@@ -19886,12 +20146,12 @@ var PACKAGE_VERSION;
19886
20146
  var init_version = __esm({
19887
20147
  "src/version.ts"() {
19888
20148
  "use strict";
19889
- PACKAGE_VERSION = "0.2.14";
20149
+ PACKAGE_VERSION = "0.2.16";
19890
20150
  }
19891
20151
  });
19892
20152
 
19893
20153
  // src/mcp/mcp-tool-schemas.ts
19894
- var import_zod26, HarvestPaaInputSchema, ExtractUrlInputSchema, MapSiteUrlsInputSchema, ExtractSiteInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, FacebookAdTranscribeInputSchema, MapsPlaceIntelInputSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchAttemptOutput, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryWorkflowOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, ExtractSiteOutputSchema, MapsPlaceIntelOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, FacebookPageIntelOutputSchema, CreditsInfoInputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema;
20154
+ var import_zod26, HarvestPaaInputSchema, ExtractUrlInputSchema, MapSiteUrlsInputSchema, ExtractSiteInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, FacebookAdTranscribeInputSchema, FacebookVideoTranscribeInputSchema, MapsPlaceIntelInputSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchAttemptOutput, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryWorkflowOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, ExtractSiteOutputSchema, MapsPlaceIntelOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, FacebookPageIntelOutputSchema, FacebookVideoTranscribeOutputSchema, CreditsInfoInputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema;
19895
20155
  var init_mcp_tool_schemas = __esm({
19896
20156
  "src/mcp/mcp-tool-schemas.ts"() {
19897
20157
  "use strict";
@@ -19946,7 +20206,11 @@ var init_mcp_tool_schemas = __esm({
19946
20206
  maxResults: import_zod26.z.number().int().min(1).max(20).default(10)
19947
20207
  };
19948
20208
  FacebookAdTranscribeInputSchema = {
19949
- videoUrl: import_zod26.z.string().url().describe("Facebook CDN video URL from a facebook_page_intel result")
20209
+ videoUrl: import_zod26.z.string().url().describe("Direct Facebook CDN video URL from a facebook_page_intel ad result. Do not pass a public Facebook reel/post/share URL here; use facebook_video_transcribe for organic Facebook URLs.")
20210
+ };
20211
+ FacebookVideoTranscribeInputSchema = {
20212
+ url: import_zod26.z.string().url().describe("Organic Facebook reel, video, watch, post, or share URL from facebook.com, m.facebook.com, or fb.watch. The tool renders the page, extracts the best matching public Facebook CDN MP4 URL, then transcribes it. Use this when the user pastes a normal Facebook video page URL and asks for the transcript or downloadable MP4."),
20213
+ quality: import_zod26.z.enum(["best", "hd", "sd"]).default("best").describe("Preferred progressive MP4 quality. Use best by default; hd prefers the highest HD progressive URL; sd forces the SD URL.")
19950
20214
  };
19951
20215
  MapsPlaceIntelInputSchema = {
19952
20216
  businessName: import_zod26.z.string().min(1).describe('Business name only. If user says "Elite Roofing Denver CO", use businessName="Elite Roofing" and location="Denver, CO".'),
@@ -20313,6 +20577,24 @@ var init_mcp_tool_schemas = __esm({
20313
20577
  variations: import_zod26.z.number().int().nullable()
20314
20578
  }))
20315
20579
  };
20580
+ FacebookVideoTranscribeOutputSchema = {
20581
+ sourceUrl: import_zod26.z.string().url(),
20582
+ pageUrl: import_zod26.z.string().url(),
20583
+ videoId: NullableString,
20584
+ ownerName: NullableString,
20585
+ selectedQuality: import_zod26.z.string(),
20586
+ bitrate: import_zod26.z.number().int().nullable(),
20587
+ videoDurationSec: import_zod26.z.number().nullable(),
20588
+ videoUrl: import_zod26.z.string().url(),
20589
+ wordCount: import_zod26.z.number().int().min(0),
20590
+ chunkCount: import_zod26.z.number().int().min(0),
20591
+ transcriptText: import_zod26.z.string(),
20592
+ chunks: import_zod26.z.array(import_zod26.z.object({
20593
+ startSec: import_zod26.z.number(),
20594
+ endSec: import_zod26.z.number(),
20595
+ text: import_zod26.z.string()
20596
+ }))
20597
+ };
20316
20598
  CreditsInfoInputSchema = {
20317
20599
  item: import_zod26.z.string().optional().describe('Optional tool, action, or feature to look up, e.g. "maps reviews", "extract_url", "YouTube transcription", or "concurrency"'),
20318
20600
  includeLedger: import_zod26.z.boolean().default(false).describe("Whether to include recent credit ledger entries")
@@ -20802,7 +21084,7 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
20802
21084
  }, async (input) => formatYoutubeTranscribe(await executor.youtubeTranscribe(input), input));
20803
21085
  server.registerTool("facebook_page_intel", {
20804
21086
  title: "Facebook Advertiser Ad Intel",
20805
- description: withReportNote("Harvest ads from a Facebook advertiser. Returns ad copy, headlines, CTAs, creative type, status, landing URLs, and video URLs ready for transcription. Accepts pageId, libraryId, or a brand/advertiser name as query. Use after facebook_ad_search when possible."),
21087
+ description: withReportNote("Harvest ads from a Facebook advertiser. Returns ad copy, headlines, CTAs, creative type, status, landing URLs, and direct ad video URLs ready for facebook_ad_transcribe. Accepts pageId, libraryId, or a brand/advertiser name as query. Use after facebook_ad_search when possible. For normal public Facebook reels/posts/watch/share URLs, use facebook_video_transcribe instead."),
20806
21088
  inputSchema: FacebookPageIntelInputSchema,
20807
21089
  outputSchema: FacebookPageIntelOutputSchema,
20808
21090
  annotations: liveWebToolAnnotations("Facebook Advertiser Ad Intel")
@@ -20816,10 +21098,17 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
20816
21098
  }, async (input) => formatFacebookAdSearch(await executor.facebookAdSearch(input), input));
20817
21099
  server.registerTool("facebook_ad_transcribe", {
20818
21100
  title: "Facebook Ad Transcription",
20819
- description: "Transcribe audio from a Facebook ad video. Returns full transcript and timestamped chunks. Use the videoUrl value from facebook_page_intel results.",
21101
+ description: "Transcribe audio from a Facebook ad video CDN URL. Returns full transcript and timestamped chunks. Use only with the direct videoUrl value from facebook_page_intel results, not public Facebook post/reel/share URLs.",
20820
21102
  inputSchema: FacebookAdTranscribeInputSchema,
20821
21103
  annotations: liveWebToolAnnotations("Facebook Ad Transcription")
20822
21104
  }, async (input) => formatFacebookAdTranscribe(await executor.facebookAdTranscribe(input), input));
21105
+ server.registerTool("facebook_video_transcribe", {
21106
+ title: "Facebook Organic Video Transcription",
21107
+ description: withReportNote("Transcribe audio from an organic Facebook reel, video, watch, post, or share URL, including fb.watch links. Use this when the user pastes a normal Facebook video page URL and wants the transcript or downloadable MP4. Renders the Facebook page in a browser, selects the best matching public Facebook CDN MP4 URL from page state, then returns sourceUrl, resolved pageUrl, videoId, ownerName, selectedQuality, bitrate, videoDurationSec, extracted MP4 URL, full transcript, and timestamped chunks."),
21108
+ inputSchema: FacebookVideoTranscribeInputSchema,
21109
+ outputSchema: FacebookVideoTranscribeOutputSchema,
21110
+ annotations: liveWebToolAnnotations("Facebook Organic Video Transcription")
21111
+ }, async (input) => formatFacebookVideoTranscribe(await executor.facebookVideoTranscribe(input), input));
20823
21112
  server.registerTool("maps_place_intel", {
20824
21113
  title: "Google Maps Business Profile Details",
20825
21114
  description: withReportNote('Extract Google Maps business intelligence for one known/named business: rating, review count, category, address, phone, website, hours, booking URL, review histogram, review topics, about attributes, entity IDs, and optional review cards. Do not use this for category searches, local market prospect lists, or requests for multiple GMB/GBP profiles; use maps_search first for those. Split business name from location (e.g. "Elite Roofing Denver CO" => businessName "Elite Roofing", location "Denver, CO"). Pass includeReviews true when the user asks for reviews/customer pain.'),
@@ -20968,6 +21257,9 @@ var init_http_mcp_tool_executor = __esm({
20968
21257
  facebookAdTranscribe(input) {
20969
21258
  return this.call("/facebook/transcribe", input);
20970
21259
  }
21260
+ facebookVideoTranscribe(input) {
21261
+ return this.call("/facebook/video-transcribe", input, this.httpTimeoutOverrideMs ?? 24e4);
21262
+ }
20971
21263
  mapsPlaceIntel(input) {
20972
21264
  return this.call("/maps/place", input);
20973
21265
  }
@@ -22623,7 +22915,7 @@ __export(server_exports, {
22623
22915
  app: () => app
22624
22916
  });
22625
22917
  function configuredOrigins() {
22626
- const origins = /* @__PURE__ */ new Set(["https://mcpscraper.dev"]);
22918
+ const origins = /* @__PURE__ */ new Set(["https://mcpscraper.dev", "https://www.mcpscraper.dev"]);
22627
22919
  for (const raw of (process.env.ALLOWED_ORIGINS ?? process.env.APP_ORIGIN ?? "").split(",")) {
22628
22920
  const trimmed = raw.trim();
22629
22921
  if (trimmed) origins.add(trimmed);