mcp-scraper 0.4.12 → 0.4.14

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 (35) hide show
  1. package/dist/bin/api-server.cjs +240 -218
  2. package/dist/bin/api-server.cjs.map +1 -1
  3. package/dist/bin/api-server.js +2 -2
  4. package/dist/bin/mcp-scraper-cli.cjs +1 -1
  5. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  6. package/dist/bin/mcp-scraper-cli.js +1 -1
  7. package/dist/bin/mcp-scraper-install.cjs +1 -1
  8. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  9. package/dist/bin/mcp-scraper-install.js +1 -1
  10. package/dist/bin/mcp-stdio-server.cjs +1 -1
  11. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  12. package/dist/bin/mcp-stdio-server.js +2 -2
  13. package/dist/bin/paa-harvest.js +1 -1
  14. package/dist/{chunk-2YY46QYT.js → chunk-AUCXKRRH.js} +3 -1
  15. package/dist/chunk-OL65BO4S.js +7 -0
  16. package/dist/chunk-OL65BO4S.js.map +1 -0
  17. package/dist/{chunk-EMY7ELRU.js → chunk-SIPXEFTR.js} +14 -10
  18. package/dist/chunk-SIPXEFTR.js.map +1 -0
  19. package/dist/{chunk-ONIOF5XW.js → chunk-VQV7MB2S.js} +2 -2
  20. package/dist/{chunk-6EXP6DQG.js → chunk-XZ2GPRCM.js} +2 -2
  21. package/dist/index.js +1 -1
  22. package/dist/{server-UNID3SJU.js → server-TGORO4AJ.js} +219 -208
  23. package/dist/server-TGORO4AJ.js.map +1 -0
  24. package/dist/{site-extract-repository-HTIY52MW.js → site-extract-repository-6X2C7RLF.js} +3 -3
  25. package/dist/{worker-HTYZAYGB.js → worker-WCSKUAOT.js} +3 -3
  26. package/package.json +1 -1
  27. package/dist/chunk-EMY7ELRU.js.map +0 -1
  28. package/dist/chunk-SHXJQQOH.js +0 -7
  29. package/dist/chunk-SHXJQQOH.js.map +0 -1
  30. package/dist/server-UNID3SJU.js.map +0 -1
  31. /package/dist/{chunk-2YY46QYT.js.map → chunk-AUCXKRRH.js.map} +0 -0
  32. /package/dist/{chunk-ONIOF5XW.js.map → chunk-VQV7MB2S.js.map} +0 -0
  33. /package/dist/{chunk-6EXP6DQG.js.map → chunk-XZ2GPRCM.js.map} +0 -0
  34. /package/dist/{site-extract-repository-HTIY52MW.js.map → site-extract-repository-6X2C7RLF.js.map} +0 -0
  35. /package/dist/{worker-HTYZAYGB.js.map → worker-WCSKUAOT.js.map} +0 -0
@@ -7986,7 +7986,7 @@ function insufficientBalanceResponse(balanceMc, requiredMc) {
7986
7986
  topup_url: topupUrl
7987
7987
  };
7988
7988
  }
7989
- var MC_COSTS, MC_PER_BROWSER_MS, BROWSER_OPEN_MIN_BALANCE_MC, MC_PER_CREDIT, CREDIT_COST_CATALOG, CONCURRENCY_PRICE_ID, SUBSCRIPTION_TIERS, SUBSCRIPTION_TIER_BY_KEY, CONCURRENCY_SLOT_PRICE_USD, CONCURRENCY_SLOT_PRICE_CURRENCY, CONCURRENCY_SLOT_PRICE_INTERVAL, CONCURRENCY_SLOT_PRICE_LABEL, CONCURRENCY_SLOT_TERMINAL_COMMAND, FREE_SIGNUP_MC, MEMORY_FREE_QUOTA_BYTES, MEMORY_PLANS, MEMORY_PLAN_QUOTA, MEMORY_MARGIN_MULTIPLE, MEMORY_PRO_COST_BUDGET_USD, SCHEDULING_PLANS, LedgerOperation, MEMORY_AI_MARGIN_MULTIPLE, SITE_AUDIT_LLM_MARGIN_MULTIPLE, VIDEO_ANALYSIS_LLM_MARGIN_MULTIPLE, MC_PER_USD;
7989
+ var MC_COSTS, MC_PER_BROWSER_MS, BROWSER_OPEN_MIN_BALANCE_MC, MC_PER_CREDIT, CREDIT_COST_CATALOG, CONCURRENCY_PRICE_ID, SUBSCRIPTION_TIERS, SUBSCRIPTION_TIER_BY_KEY, CONCURRENCY_SLOT_PRICE_USD, CONCURRENCY_SLOT_PRICE_CURRENCY, CONCURRENCY_SLOT_PRICE_INTERVAL, CONCURRENCY_SLOT_PRICE_LABEL, CONCURRENCY_SLOT_TERMINAL_COMMAND, FREE_SIGNUP_MC, MEMORY_FREE_QUOTA_BYTES, MEMORY_PLANS, MEMORY_PLAN_QUOTA, MEMORY_MARGIN_MULTIPLE, MEMORY_PRO_COST_BUDGET_USD, SCHEDULING_PLANS, LedgerOperation, MEMORY_AI_MARGIN_MULTIPLE, SITE_AUDIT_LLM_MARGIN_MULTIPLE, VIDEO_ANALYSIS_LLM_MARGIN_MULTIPLE, MEDIA_TRANSCRIBE_MARGIN_MULTIPLE, MEDIA_TRANSCRIBE_HOLD_MC, MC_PER_USD;
7990
7990
  var init_rates = __esm({
7991
7991
  "src/api/rates.ts"() {
7992
7992
  "use strict";
@@ -8003,13 +8003,13 @@ var init_rates = __esm({
8003
8003
  maps_place: 6e3,
8004
8004
  maps_review: 100,
8005
8005
  fb_search: 600,
8006
- fb_transcribe: 200,
8006
+ fb_transcribe: 33300,
8007
8007
  google_ads_search: 600,
8008
8008
  google_ads_intel: 200,
8009
- google_ads_transcribe: 200,
8009
+ google_ads_transcribe: 33300,
8010
8010
  instagram_profile: 400,
8011
8011
  instagram_media: 400,
8012
- instagram_transcribe: 50,
8012
+ instagram_transcribe: 33300,
8013
8013
  browser_minute: 12e3,
8014
8014
  reddit_thread: 3e3,
8015
8015
  reddit_comment: 200,
@@ -8131,16 +8131,16 @@ var init_rates = __esm({
8131
8131
  label: "Google ad video transcription",
8132
8132
  aliases: ["google_ads_transcribe", "google ad transcript"],
8133
8133
  credits: mcToCredits(MC_COSTS.google_ads_transcribe),
8134
- unit: "per minute",
8135
- notes: "A hold is taken, then reconciled to actual video duration."
8134
+ unit: "per minute (typical)",
8135
+ notes: "A hold is taken, then reconciled to the real transcription cost for the actual audio duration."
8136
8136
  },
8137
8137
  {
8138
8138
  key: "fb_transcribe",
8139
8139
  label: "Facebook video / ad transcription",
8140
8140
  aliases: ["facebook_transcribe", "facebook_video_transcribe", "fb_transcribe", "fb ad transcript"],
8141
8141
  credits: mcToCredits(MC_COSTS.fb_transcribe),
8142
- unit: "per minute",
8143
- notes: "A hold is taken, then reconciled to actual video duration."
8142
+ unit: "per minute (typical)",
8143
+ notes: "A hold is taken, then reconciled to the real transcription cost for the actual audio duration."
8144
8144
  },
8145
8145
  {
8146
8146
  key: "instagram_profile",
@@ -8163,8 +8163,8 @@ var init_rates = __esm({
8163
8163
  label: "Instagram media transcription",
8164
8164
  aliases: ["instagram transcript", "instagram reel transcript", "ig transcribe"],
8165
8165
  credits: mcToCredits(MC_COSTS.instagram_transcribe),
8166
- unit: "per call",
8167
- notes: "Whisper transcription of selected Instagram audio/video media via fal.ai."
8166
+ unit: "per minute (typical)",
8167
+ notes: "Whisper transcription of selected Instagram audio/video media via fal.ai. A hold is taken, then reconciled to the real transcription cost for the actual audio duration."
8168
8168
  },
8169
8169
  {
8170
8170
  key: "browser_minute",
@@ -8333,6 +8333,8 @@ var init_rates = __esm({
8333
8333
  MEMORY_AI_MARGIN_MULTIPLE = 3;
8334
8334
  SITE_AUDIT_LLM_MARGIN_MULTIPLE = 1.5;
8335
8335
  VIDEO_ANALYSIS_LLM_MARGIN_MULTIPLE = 1.5;
8336
+ MEDIA_TRANSCRIBE_MARGIN_MULTIPLE = 3;
8337
+ MEDIA_TRANSCRIBE_HOLD_MC = MC_COSTS.fb_transcribe * 3;
8336
8338
  MC_PER_USD = SUBSCRIPTION_TIERS["price_1TmiHRS8aAcsk3TGwmSNfNIa"].credits_mc / SUBSCRIPTION_TIERS["price_1TmiHRS8aAcsk3TGwmSNfNIa"].monthly_usd;
8337
8339
  }
8338
8340
  });
@@ -13537,6 +13539,104 @@ var init_kernel_proxy_resolver = __esm({
13537
13539
  }
13538
13540
  });
13539
13541
 
13542
+ // src/youtube/audio-resolver.ts
13543
+ function mediaResolverUrl() {
13544
+ return process.env.FRAMES_SERVICE_URL?.trim() || void 0;
13545
+ }
13546
+ function mediaResolverSecret() {
13547
+ return process.env.FRAMES_SERVICE_SECRET?.trim() || void 0;
13548
+ }
13549
+ function mediaResolverConfigured() {
13550
+ return Boolean(mediaResolverUrl() && mediaResolverSecret());
13551
+ }
13552
+ async function resolveYouTubeAudio(videoId) {
13553
+ const base = mediaResolverUrl();
13554
+ const secret2 = mediaResolverSecret();
13555
+ if (!base || !secret2) return null;
13556
+ const controller = new AbortController();
13557
+ const timer = setTimeout(() => controller.abort(), RESOLVE_TIMEOUT_MS);
13558
+ try {
13559
+ const resp = await fetch(`${base.replace(/\/+$/, "")}/resolve-audio`, {
13560
+ method: "POST",
13561
+ headers: { "content-type": "application/json", "x-frames-secret": secret2 },
13562
+ body: JSON.stringify({ videoUrl: `https://www.youtube.com/watch?v=${videoId}` }),
13563
+ signal: controller.signal
13564
+ });
13565
+ if (!resp.ok) {
13566
+ const detail = await resp.text().catch(() => "");
13567
+ console.warn(`[audio-resolver] ${videoId}: http_${resp.status} ${detail.slice(0, 200)}`);
13568
+ return null;
13569
+ }
13570
+ const data = await resp.json();
13571
+ if (!data.mediaUrl) {
13572
+ console.warn(`[audio-resolver] ${videoId}: response missing mediaUrl`);
13573
+ return null;
13574
+ }
13575
+ return {
13576
+ mediaUrl: data.mediaUrl,
13577
+ durationS: Number(data.durationS) || 0,
13578
+ sizeBytes: Number(data.sizeBytes) || 0,
13579
+ mimeType: data.mimeType ?? "audio/mp4"
13580
+ };
13581
+ } catch (err) {
13582
+ const msg = err instanceof Error ? err.message : String(err);
13583
+ console.warn(`[audio-resolver] ${videoId}: resolve failed \u2014 ${msg}`);
13584
+ return null;
13585
+ } finally {
13586
+ clearTimeout(timer);
13587
+ }
13588
+ }
13589
+ var RESOLVE_TIMEOUT_MS;
13590
+ var init_audio_resolver = __esm({
13591
+ "src/youtube/audio-resolver.ts"() {
13592
+ "use strict";
13593
+ RESOLVE_TIMEOUT_MS = 24e4;
13594
+ }
13595
+ });
13596
+
13597
+ // src/services/media-transcription.ts
13598
+ function transcriptMarkdown(title, text, chunks, durationMs) {
13599
+ const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
13600
+ const lines = [title, "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
13601
+ if (chunks.length) {
13602
+ lines.push("## Timestamped Segments", "");
13603
+ for (const ch of chunks) {
13604
+ lines.push(`**[${fmtTs2(ch.timestamp[0])} -> ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
13605
+ }
13606
+ }
13607
+ return lines.join("\n");
13608
+ }
13609
+ async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript") {
13610
+ const startMs = Date.now();
13611
+ const result = await import_client3.fal.subscribe("fal-ai/wizper", {
13612
+ input: { audio_url: mediaUrl, task: "transcribe", language: "en" },
13613
+ logs: false,
13614
+ pollInterval: 3e3
13615
+ });
13616
+ const data = result.data;
13617
+ const text = data.text ?? "";
13618
+ const chunks = data.chunks ?? [];
13619
+ const durationMs = Date.now() - startMs;
13620
+ void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: durationMs / 1e3, unitType: "compute_sec" });
13621
+ const costUsd = vendorCostUsd("fal_wizper", durationMs / 1e3);
13622
+ return {
13623
+ text,
13624
+ chunks,
13625
+ durationMs,
13626
+ costUsd,
13627
+ markdown: transcriptMarkdown(markdownTitle, text, chunks, durationMs)
13628
+ };
13629
+ }
13630
+ var import_client3;
13631
+ var init_media_transcription = __esm({
13632
+ "src/services/media-transcription.ts"() {
13633
+ "use strict";
13634
+ import_client3 = require("@fal-ai/client");
13635
+ init_cost_telemetry();
13636
+ init_cost_rates();
13637
+ }
13638
+ });
13639
+
13540
13640
  // src/youtube/CaptionFetcher.ts
13541
13641
  async function fetchViaYoutubeTranscript(videoId) {
13542
13642
  try {
@@ -13605,13 +13705,12 @@ function parseTimedtextXml(xml) {
13605
13705
  }
13606
13706
  return results;
13607
13707
  }
13608
- function isBotBlockStatus(status) {
13609
- return status === "LOGIN_REQUIRED" || status === "AGE_VERIFICATION_REQUIRED";
13610
- }
13611
13708
  async function probePlayer(page, videoId) {
13612
13709
  return page.evaluate(
13613
13710
  async ({ vid, clients }) => {
13614
13711
  let lastStatus = null;
13712
+ let sawBotBlock = false;
13713
+ let sawAccessible = false;
13615
13714
  for (const client2 of clients) {
13616
13715
  const resp = await fetch("/youtubei/v1/player?prettyPrint=false", {
13617
13716
  method: "POST",
@@ -13627,97 +13726,24 @@ async function probePlayer(page, videoId) {
13627
13726
  }
13628
13727
  const data = await resp.json();
13629
13728
  lastStatus = data?.playabilityStatus?.status ?? null;
13630
- const audioFormats = (data?.streamingData?.adaptiveFormats ?? []).filter((f) => typeof f.url === "string" && f.url && typeof f.mimeType === "string" && f.mimeType.startsWith("audio/")).sort((a, b) => (Number(a.contentLength) || Infinity) - (Number(b.contentLength) || Infinity));
13631
- const pick = audioFormats[0] ?? null;
13632
- let captions = null;
13729
+ if (lastStatus === "LOGIN_REQUIRED" || lastStatus === "AGE_VERIFICATION_REQUIRED") sawBotBlock = true;
13730
+ else if (lastStatus === "OK") sawAccessible = true;
13633
13731
  const tracks = data?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
13634
13732
  if (tracks.length > 0) {
13635
13733
  const track = tracks.find((t) => t.languageCode === "en") ?? tracks[0];
13636
13734
  const sep = track.baseUrl.includes("?") ? "&" : "?";
13637
13735
  const xmlResp = await fetch(`${track.baseUrl}${sep}fmt=json3`).catch(() => null);
13638
- if (xmlResp?.ok) captions = await xmlResp.text();
13736
+ if (xmlResp?.ok) {
13737
+ const captions = await xmlResp.text();
13738
+ if (captions) return { captions, status: lastStatus, escalate: false };
13739
+ }
13639
13740
  }
13640
- if (!captions && !pick) continue;
13641
- return {
13642
- captions,
13643
- status: lastStatus,
13644
- audio: pick ? {
13645
- url: pick.url,
13646
- mimeType: pick.mimeType,
13647
- bitrate: pick.bitrate ?? null,
13648
- contentLength: pick.contentLength ? Number(pick.contentLength) || null : null,
13649
- approxDurationMs: pick.approxDurationMs ? Number(pick.approxDurationMs) || null : null
13650
- } : null
13651
- };
13652
13741
  }
13653
- return { captions: null, audio: null, status: lastStatus };
13742
+ return { captions: null, status: lastStatus, escalate: sawBotBlock && !sawAccessible };
13654
13743
  },
13655
13744
  { vid: videoId, clients: INNERTUBE_CLIENTS }
13656
13745
  ).catch(() => null);
13657
13746
  }
13658
- async function downloadWholeAudioStreamInOneRequestBecauseYouTubeAllowsOnlyOnePerIp(page, audio) {
13659
- if (audio.contentLength && audio.contentLength > MAX_AUDIO_BYTES) {
13660
- console.warn(`[CaptionFetcher] audio stream ${audio.contentLength} bytes exceeds ${MAX_AUDIO_BYTES} byte cap`);
13661
- return null;
13662
- }
13663
- const sep = audio.url.includes("?") ? "&" : "?";
13664
- const url = audio.contentLength ? `${audio.url}${sep}range=0-${audio.contentLength - 1}` : audio.url;
13665
- const result = await page.evaluate(async (u) => {
13666
- try {
13667
- const resp = await fetch(u);
13668
- if (!resp.ok) return { ok: false, error: `http_${resp.status}` };
13669
- const blob = await resp.blob();
13670
- const dataUrl = await new Promise((resolve, reject) => {
13671
- const reader = new FileReader();
13672
- reader.onload = () => resolve(reader.result);
13673
- reader.onerror = () => reject(reader.error);
13674
- reader.readAsDataURL(blob);
13675
- });
13676
- return { ok: true, base64: dataUrl.slice(dataUrl.indexOf(",") + 1) };
13677
- } catch (e) {
13678
- return { ok: false, error: String(e) };
13679
- }
13680
- }, url).catch((e) => ({ ok: false, error: String(e) }));
13681
- if (!result.ok || !result.base64) {
13682
- console.warn(`[CaptionFetcher] audio fetch failed (${result.error ?? "unknown"}) for a ${audio.contentLength ?? 0} byte stream`);
13683
- return null;
13684
- }
13685
- const bytes = Buffer.from(result.base64, "base64");
13686
- if (audio.contentLength && bytes.length < audio.contentLength) {
13687
- console.warn(`[CaptionFetcher] partial audio: ${bytes.length} of ${audio.contentLength} bytes`);
13688
- return null;
13689
- }
13690
- return { bytes, mimeType: audio.mimeType.split(";")[0].trim() };
13691
- }
13692
- async function transcribeWithWizper(videoId, audio, startMs) {
13693
- const falKey = process.env.FAL_KEY;
13694
- if (!falKey) return null;
13695
- try {
13696
- import_client3.fal.config({ credentials: falKey });
13697
- const ext = audio.mimeType.includes("webm") ? "webm" : audio.mimeType.includes("mp4") ? "m4a" : "audio";
13698
- const audioFile = new File([new Uint8Array(audio.bytes)], `audio.${ext}`, { type: audio.mimeType });
13699
- const uploadedUrl = await import_client3.fal.storage.upload(audioFile);
13700
- const subscribeStartMs = Date.now();
13701
- const result = await import_client3.fal.subscribe("fal-ai/wizper", {
13702
- input: { audio_url: uploadedUrl, task: "transcribe", language: "en", chunk_level: "segment" },
13703
- logs: false,
13704
- pollInterval: 3e3
13705
- });
13706
- const subscribeDurationMs = Date.now() - subscribeStartMs;
13707
- const data = result.data;
13708
- const chunks = (data.chunks ?? []).map((c) => ({
13709
- timestamp: c.timestamp,
13710
- text: c.text.trim()
13711
- }));
13712
- void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: subscribeDurationMs / 1e3, unitType: "compute_sec" });
13713
- const text = data.text ?? chunks.map((c) => c.text).join(" ");
13714
- if (!text) return null;
13715
- return { videoId, text, chunks, durationMs: Date.now() - startMs, method: "browser-whisper" };
13716
- } catch (err) {
13717
- console.warn(`[CaptionFetcher] wizper transcription failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
13718
- return null;
13719
- }
13720
- }
13721
13747
  async function resolveResidentialProxy(kernelApiKey) {
13722
13748
  try {
13723
13749
  const resolved = await resolveKernelProxyId({
@@ -13733,14 +13759,13 @@ async function resolveResidentialProxy(kernelApiKey) {
13733
13759
  return {};
13734
13760
  }
13735
13761
  }
13736
- async function probeAndExtract(videoId, opts) {
13762
+ async function probeCaptions(videoId, kernelProxyIdOverride) {
13737
13763
  const kernelApiKey = browserServiceApiKey();
13738
13764
  if (!kernelApiKey) return null;
13739
- const kernelProxyId = opts?.kernelProxyId ?? browserServiceProxyId();
13765
+ const kernelProxyId = kernelProxyIdOverride ?? browserServiceProxyId();
13740
13766
  const driver = new BrowserDriver();
13741
13767
  return runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: "browser-innertube" }, async () => {
13742
13768
  let entries = [];
13743
- let audio = null;
13744
13769
  let retryWithFreshIp = false;
13745
13770
  try {
13746
13771
  await driver.launch({ kernelApiKey, kernelProxyId, headless: true, headlessMode: "headless", viewport: { width: 1280, height: 800 }, locale: "en-US" });
@@ -13752,69 +13777,73 @@ async function probeAndExtract(videoId, opts) {
13752
13777
  );
13753
13778
  const probe = await probePlayer(page, videoId);
13754
13779
  if (!probe) return null;
13755
- if (probe.captions && !opts?.skipCaptions) {
13780
+ if (probe.captions) {
13756
13781
  entries = parseTimedtextJson3(probe.captions);
13757
13782
  if (entries.length === 0) entries = parseTimedtextXml(probe.captions);
13758
13783
  }
13759
13784
  if (entries.length === 0) {
13760
- if (!probe.audio) {
13761
- retryWithFreshIp = isBotBlockStatus(probe.status);
13762
- console.warn(`[CaptionFetcher] no captions and no audio stream for ${videoId} (playability=${probe.status ?? "unknown"}, proxied=${Boolean(kernelProxyId)})`);
13763
- return { entries, audio: null, retryWithFreshIp };
13764
- }
13765
- audio = await downloadWholeAudioStreamInOneRequestBecauseYouTubeAllowsOnlyOnePerIp(page, probe.audio);
13766
- if (!audio) return { entries, audio: null, retryWithFreshIp: true };
13785
+ retryWithFreshIp = probe.escalate;
13786
+ console.warn(`[CaptionFetcher] no caption track for ${videoId} (playability=${probe.status ?? "unknown"}, proxied=${Boolean(kernelProxyId)}, escalate=${probe.escalate})`);
13767
13787
  }
13768
13788
  } catch (err) {
13769
13789
  console.warn(`[CaptionFetcher] kernel session failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
13770
13790
  return null;
13771
13791
  } finally {
13772
- const servedSubOp = audio ? "browser-whisper" : "browser-innertube";
13773
- await runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: servedSubOp }, () => driver.close());
13792
+ await driver.close();
13774
13793
  }
13775
- return { entries, audio, retryWithFreshIp };
13794
+ return { entries, retryWithFreshIp };
13776
13795
  });
13777
13796
  }
13778
- async function extractWithProxyEscalation(videoId, opts) {
13779
- const direct = await probeAndExtract(videoId, opts);
13780
- if (direct && (direct.entries.length > 0 || direct.audio)) return direct;
13781
- const shouldEscalate = direct === null || direct.retryWithFreshIp;
13782
- if (!shouldEscalate) return direct;
13783
- const kernelApiKey = browserServiceApiKey();
13784
- if (!kernelApiKey) return direct;
13785
- const { kernelProxyId, disposableProxyId } = await resolveResidentialProxy(kernelApiKey);
13786
- if (!kernelProxyId) return direct;
13787
- try {
13788
- console.warn(`[CaptionFetcher] ${videoId}: retrying through residential proxy after bot block`);
13789
- const viaProxy = await probeAndExtract(videoId, { ...opts, kernelProxyId });
13790
- return viaProxy ?? direct;
13791
- } finally {
13792
- if (disposableProxyId) {
13793
- await deleteKernelProxyId(kernelApiKey, disposableProxyId).catch(
13794
- (err) => console.warn(`[CaptionFetcher] failed to delete disposable proxy: ${err instanceof Error ? err.message : String(err)}`)
13795
- );
13796
- }
13797
- }
13798
- }
13799
13797
  async function fetchViaKernelSession(videoId) {
13800
13798
  const start = Date.now();
13801
- const outcome = await extractWithProxyEscalation(videoId);
13802
- if (!outcome) return null;
13803
- if (outcome.entries.length > 0) {
13804
- const chunks = outcome.entries.map((e) => ({
13805
- timestamp: [e.start / 1e3, (e.start + e.dur) / 1e3],
13806
- text: e.text
13807
- }));
13808
- const text = chunks.map((c) => c.text).join(" ");
13809
- return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-innertube" };
13810
- }
13811
- if (outcome.audio) {
13812
- return runWithCostContext(
13813
- { ...currentCostContext(), op: "yt_transcription", subOp: "browser-whisper" },
13814
- () => transcribeWithWizper(videoId, outcome.audio, start)
13815
- );
13799
+ let outcome = await probeCaptions(videoId);
13800
+ if (!outcome || outcome.entries.length === 0 && outcome.retryWithFreshIp) {
13801
+ const kernelApiKey = browserServiceApiKey();
13802
+ if (kernelApiKey) {
13803
+ const { kernelProxyId, disposableProxyId } = await resolveResidentialProxy(kernelApiKey);
13804
+ if (kernelProxyId) {
13805
+ try {
13806
+ console.warn(`[CaptionFetcher] ${videoId}: retrying captions through residential proxy after bot block`);
13807
+ outcome = await probeCaptions(videoId, kernelProxyId) ?? outcome;
13808
+ } finally {
13809
+ if (disposableProxyId) {
13810
+ await deleteKernelProxyId(kernelApiKey, disposableProxyId).catch(
13811
+ (err) => console.warn(`[CaptionFetcher] failed to delete disposable proxy: ${err instanceof Error ? err.message : String(err)}`)
13812
+ );
13813
+ }
13814
+ }
13815
+ }
13816
+ }
13816
13817
  }
13817
- return null;
13818
+ if (!outcome || outcome.entries.length === 0) return null;
13819
+ const chunks = outcome.entries.map((e) => ({
13820
+ timestamp: [e.start / 1e3, (e.start + e.dur) / 1e3],
13821
+ text: e.text
13822
+ }));
13823
+ const text = chunks.map((c) => c.text).join(" ");
13824
+ return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-innertube" };
13825
+ }
13826
+ async function fetchViaResolvedAudio(videoId) {
13827
+ if (!mediaResolverConfigured()) return null;
13828
+ return runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: "audio-whisper" }, async () => {
13829
+ const resolved = await resolveYouTubeAudio(videoId);
13830
+ if (!resolved) return null;
13831
+ try {
13832
+ const transcript = await transcribeMediaUrl(resolved.mediaUrl, `# Transcript \u2014 ${videoId}`);
13833
+ if (!transcript.text) return null;
13834
+ const chunks = transcript.chunks.map((c) => ({ timestamp: c.timestamp, text: c.text.trim() }));
13835
+ return {
13836
+ videoId,
13837
+ text: transcript.text,
13838
+ chunks,
13839
+ durationMs: resolved.durationS > 0 ? Math.round(resolved.durationS * 1e3) : transcript.durationMs,
13840
+ method: "audio-whisper"
13841
+ };
13842
+ } catch (err) {
13843
+ console.warn(`[CaptionFetcher] transcription of resolved audio failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
13844
+ return null;
13845
+ }
13846
+ });
13818
13847
  }
13819
13848
  function finalizeCaptions(result) {
13820
13849
  const chunks = result.chunks.map((c) => {
@@ -13836,18 +13865,21 @@ async function fetchCaptions(videoId) {
13836
13865
  if (primary) return finalizeCaptions(primary);
13837
13866
  const result = await fetchViaKernelSession(videoId);
13838
13867
  if (result) return finalizeCaptions(result);
13868
+ const viaResolvedAudio = await fetchViaResolvedAudio(videoId);
13869
+ if (viaResolvedAudio) return finalizeCaptions(viaResolvedAudio);
13839
13870
  throw new Error(
13840
- `Could not transcribe ${videoId}. It has no caption track, and its audio stream could not be downloaded \u2014 YouTube permits a single media request per IP, so streams that do not fit in one response are unreachable. Captioned videos of any length still work.`
13871
+ `Could not transcribe ${videoId}: it has no caption track and its audio could not be retrieved.`
13841
13872
  );
13842
13873
  }
13843
- var import_client3, INNERTUBE_CLIENTS, MAX_AUDIO_BYTES, YT_RESIDENTIAL_LOCATION;
13874
+ var INNERTUBE_CLIENTS, YT_RESIDENTIAL_LOCATION;
13844
13875
  var init_CaptionFetcher = __esm({
13845
13876
  "src/youtube/CaptionFetcher.ts"() {
13846
13877
  "use strict";
13847
13878
  init_BrowserDriver();
13848
13879
  init_browser_service_env();
13849
13880
  init_kernel_proxy_resolver();
13850
- import_client3 = require("@fal-ai/client");
13881
+ init_audio_resolver();
13882
+ init_media_transcription();
13851
13883
  init_cost_telemetry();
13852
13884
  init_cost_context();
13853
13885
  INNERTUBE_CLIENTS = [
@@ -13855,7 +13887,6 @@ var init_CaptionFetcher = __esm({
13855
13887
  { name: "IOS", version: "20.10.4", userAgent: "com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)" },
13856
13888
  { name: "ANDROID_VR", version: "1.62.27", userAgent: "com.google.android.apps.youtube.vr.oculus/1.62.27 (Linux; U; Android 12L) gzip" }
13857
13889
  ];
13858
- MAX_AUDIO_BYTES = 96 * 1024 * 1024;
13859
13890
  YT_RESIDENTIAL_LOCATION = process.env.YOUTUBE_PROXY_LOCATION ?? "Austin, Texas";
13860
13891
  }
13861
13892
  });
@@ -15392,46 +15423,6 @@ var init_schemas3 = __esm({
15392
15423
  }
15393
15424
  });
15394
15425
 
15395
- // src/services/media-transcription.ts
15396
- function transcriptMarkdown(title, text, chunks, durationMs) {
15397
- const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
15398
- const lines = [title, "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
15399
- if (chunks.length) {
15400
- lines.push("## Timestamped Segments", "");
15401
- for (const ch of chunks) {
15402
- lines.push(`**[${fmtTs2(ch.timestamp[0])} -> ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
15403
- }
15404
- }
15405
- return lines.join("\n");
15406
- }
15407
- async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript") {
15408
- const startMs = Date.now();
15409
- const result = await import_client4.fal.subscribe("fal-ai/wizper", {
15410
- input: { audio_url: mediaUrl, task: "transcribe", language: "en" },
15411
- logs: false,
15412
- pollInterval: 3e3
15413
- });
15414
- const data = result.data;
15415
- const text = data.text ?? "";
15416
- const chunks = data.chunks ?? [];
15417
- const durationMs = Date.now() - startMs;
15418
- void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: durationMs / 1e3, unitType: "compute_sec" });
15419
- return {
15420
- text,
15421
- chunks,
15422
- durationMs,
15423
- markdown: transcriptMarkdown(markdownTitle, text, chunks, durationMs)
15424
- };
15425
- }
15426
- var import_client4;
15427
- var init_media_transcription = __esm({
15428
- "src/services/media-transcription.ts"() {
15429
- "use strict";
15430
- import_client4 = require("@fal-ai/client");
15431
- init_cost_telemetry();
15432
- }
15433
- });
15434
-
15435
15426
  // src/api/facebook-ad-routes.ts
15436
15427
  function invalidRequest(message) {
15437
15428
  return { error_code: "invalid_request", message };
@@ -15474,7 +15465,7 @@ async function kernelLaunchOptsResidential() {
15474
15465
  }
15475
15466
  return { headless: true, kernelApiKey: browserServiceApiKey(), kernelProxyId: proxyId, viewport: { width: 1280, height: 900 }, locale: "en-US" };
15476
15467
  }
15477
- var import_hono5, import_zod16, import_client5, FacebookAdBodySchema, FacebookPageIntelBodySchema, FacebookTranscribeBodySchema, FacebookVideoTranscribeBodySchema, FacebookSearchBodySchema, FacebookMediaBodySchema, facebookAdApp, ALLOWED_MEDIA_HOSTS;
15468
+ var import_hono5, import_zod16, import_client4, FacebookAdBodySchema, FacebookPageIntelBodySchema, FacebookTranscribeBodySchema, FacebookVideoTranscribeBodySchema, FacebookSearchBodySchema, FacebookMediaBodySchema, facebookAdApp, ALLOWED_MEDIA_HOSTS;
15478
15469
  var init_facebook_ad_routes = __esm({
15479
15470
  "src/api/facebook-ad-routes.ts"() {
15480
15471
  "use strict";
@@ -15483,13 +15474,14 @@ var init_facebook_ad_routes = __esm({
15483
15474
  import_zod16 = require("zod");
15484
15475
  init_db();
15485
15476
  init_rates();
15477
+ init_cost_rates();
15486
15478
  init_BrowserDriver();
15487
15479
  init_FacebookAdExtractor();
15488
15480
  init_FacebookAdGraphql();
15489
15481
  init_FacebookOrganicVideoExtractor();
15490
15482
  init_kernel_proxy_resolver();
15491
15483
  init_schemas3();
15492
- import_client5 = require("@fal-ai/client");
15484
+ import_client4 = require("@fal-ai/client");
15493
15485
  init_api_auth();
15494
15486
  init_url_utils();
15495
15487
  init_concurrency_gates();
@@ -15632,17 +15624,17 @@ var init_facebook_ad_routes = __esm({
15632
15624
  metadata: { videoUrl }
15633
15625
  });
15634
15626
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
15635
- import_client5.fal.config({ credentials: process.env.FAL_KEY });
15636
- const holdMc = MC_COSTS.fb_transcribe * 5;
15627
+ import_client4.fal.config({ credentials: process.env.FAL_KEY });
15628
+ const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
15637
15629
  let debited = false;
15638
15630
  try {
15639
15631
  const { ok, balance_mc } = await debitMc(fbUser.id, holdMc, LedgerOperation.FB_TRANSCRIBE, videoUrl);
15640
15632
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
15641
15633
  debited = true;
15642
15634
  const transcript = await transcribeFacebookVideoUrl(videoUrl, "# Facebook Ad Transcript");
15643
- const actualMins = Math.max(1, Math.ceil((Number.isFinite(transcript.durationMs) ? transcript.durationMs / 1e3 : 60) / 60));
15644
- const diff = holdMc - MC_COSTS.fb_transcribe * actualMins;
15645
- if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "overestimate refund");
15635
+ const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
15636
+ const diff = holdMc - settleMc;
15637
+ if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "real-cost reconcile");
15646
15638
  else if (diff < 0) await debitMc(fbUser.id, -diff, LedgerOperation.FB_TRANSCRIBE, videoUrl);
15647
15639
  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 } });
15648
15640
  return c.json(transcript);
@@ -15676,22 +15668,24 @@ var init_facebook_ad_routes = __esm({
15676
15668
  metadata: { url: sourceUrl.href }
15677
15669
  });
15678
15670
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
15679
- import_client5.fal.config({ credentials: process.env.FAL_KEY });
15671
+ import_client4.fal.config({ credentials: process.env.FAL_KEY });
15680
15672
  const driver = new BrowserDriver();
15681
- const holdMc = MC_COSTS.fb_transcribe * 5;
15673
+ const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
15682
15674
  let debited = false;
15683
15675
  try {
15684
15676
  const { ok, balance_mc } = await debitMc(fbUser.id, holdMc, LedgerOperation.FB_TRANSCRIBE, sourceUrl.href);
15685
15677
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
15686
15678
  debited = true;
15679
+ const kernelStartMs = Date.now();
15687
15680
  await driver.launch(await kernelLaunchOptsResidential());
15688
15681
  await driver.navigateTo(sourceUrl.href);
15689
15682
  const page = driver.getPage();
15690
15683
  const video = await extractFacebookOrganicVideoFromPage(page, sourceUrl.href, body.quality);
15684
+ const kernelMs = Date.now() - kernelStartMs;
15691
15685
  const transcript = await transcribeFacebookVideoUrl(video.videoUrl, "# Facebook Organic Video Transcript");
15692
- const actualMins = Math.max(1, Math.ceil((Number.isFinite(transcript.durationMs) ? transcript.durationMs / 1e3 : 60) / 60));
15693
- const diff = holdMc - MC_COSTS.fb_transcribe * actualMins;
15694
- if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "overestimate refund");
15686
+ const settleMc = Math.round((transcript.costUsd + kernelCostUsd(kernelMs, true)) * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
15687
+ const diff = holdMc - settleMc;
15688
+ if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "real-cost reconcile");
15695
15689
  else if (diff < 0) await debitMc(fbUser.id, -diff, LedgerOperation.FB_TRANSCRIBE, sourceUrl.href);
15696
15690
  const result = {
15697
15691
  ...video,
@@ -16055,7 +16049,7 @@ async function kernelLaunchOptsResidential2() {
16055
16049
  function isBlockedMessage(msg) {
16056
16050
  return msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha");
16057
16051
  }
16058
- var import_hono6, import_zod17, import_client6, GoogleAdsSearchBodySchema, GoogleAdsPageIntelBodySchema, GoogleAdsTranscribeBodySchema, googleAdsApp;
16052
+ var import_hono6, import_zod17, import_client5, GoogleAdsSearchBodySchema, GoogleAdsPageIntelBodySchema, GoogleAdsTranscribeBodySchema, googleAdsApp;
16059
16053
  var init_google_ads_routes = __esm({
16060
16054
  "src/api/google-ads-routes.ts"() {
16061
16055
  "use strict";
@@ -16068,11 +16062,12 @@ var init_google_ads_routes = __esm({
16068
16062
  init_GoogleAdsExtractor();
16069
16063
  init_kernel_proxy_resolver();
16070
16064
  init_schemas3();
16071
- import_client6 = require("@fal-ai/client");
16065
+ import_client5 = require("@fal-ai/client");
16072
16066
  init_api_auth();
16073
16067
  init_url_utils();
16074
16068
  init_concurrency_gates();
16075
16069
  init_media_transcription();
16070
+ init_cost_context();
16076
16071
  GoogleAdsSearchBodySchema = import_zod17.z.object({
16077
16072
  query: import_zod17.z.string().trim().min(1, "query is required"),
16078
16073
  region: import_zod17.z.string().trim().toUpperCase().length(2).optional(),
@@ -16104,9 +16099,11 @@ var init_google_ads_routes = __esm({
16104
16099
  const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.google_ads_search, LedgerOperation.GOOGLE_ADS_SEARCH, body.query.trim());
16105
16100
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.google_ads_search), 402);
16106
16101
  debited = true;
16107
- await driver.launch(await kernelLaunchOptsResidential2());
16108
- const extractor = new GoogleAdsExtractor(driver);
16109
- const result = await extractor.search(body.query.trim(), region, maxResults);
16102
+ const result = await runWithCostContext({ ...currentCostContext(), op: "google_ads_search", userId: user.id }, async () => {
16103
+ await driver.launch(await kernelLaunchOptsResidential2());
16104
+ const extractor = new GoogleAdsExtractor(driver);
16105
+ return extractor.search(body.query.trim(), region, maxResults);
16106
+ });
16110
16107
  if (result.advertisers.length === 0) {
16111
16108
  await creditMc(user.id, MC_COSTS.google_ads_search, LedgerOperation.GOOGLE_ADS_SEARCH_REFUND, "empty result");
16112
16109
  refunded = true;
@@ -16142,9 +16139,11 @@ var init_google_ads_routes = __esm({
16142
16139
  const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.google_ads_intel, LedgerOperation.GOOGLE_ADS_INTEL, queryLabel);
16143
16140
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.google_ads_intel), 402);
16144
16141
  debited = true;
16145
- await driver.launch(await kernelLaunchOptsResidential2());
16146
- const extractor = new GoogleAdsExtractor(driver);
16147
- const result = await extractor.pageIntel({ advertiserId: body.advertiserId, domain: body.domain, region, maxAds });
16142
+ const result = await runWithCostContext({ ...currentCostContext(), op: "google_ads_intel", userId: user.id }, async () => {
16143
+ await driver.launch(await kernelLaunchOptsResidential2());
16144
+ const extractor = new GoogleAdsExtractor(driver);
16145
+ return extractor.pageIntel({ advertiserId: body.advertiserId, domain: body.domain, region, maxAds });
16146
+ });
16148
16147
  if (result.ads.length === 0) {
16149
16148
  await creditMc(user.id, MC_COSTS.google_ads_intel, LedgerOperation.GOOGLE_ADS_INTEL_REFUND, "empty result");
16150
16149
  refunded = true;
@@ -16174,17 +16173,17 @@ var init_google_ads_routes = __esm({
16174
16173
  const user = c.get("user");
16175
16174
  const gate = await acquireConcurrencyGate(user, "google_ads_transcribe", { reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"), metadata: { videoUrl } });
16176
16175
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
16177
- import_client6.fal.config({ credentials: process.env.FAL_KEY });
16178
- const holdMc = MC_COSTS.google_ads_transcribe * 5;
16176
+ import_client5.fal.config({ credentials: process.env.FAL_KEY });
16177
+ const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
16179
16178
  let debited = false;
16180
16179
  try {
16181
16180
  const { ok, balance_mc } = await debitMc(user.id, holdMc, LedgerOperation.GOOGLE_ADS_TRANSCRIBE, videoUrl);
16182
16181
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
16183
16182
  debited = true;
16184
- const transcript = await transcribeMediaUrl(videoUrl, "# Google Ad Transcript");
16185
- const actualMins = Math.max(1, Math.ceil((Number.isFinite(transcript.durationMs) ? transcript.durationMs / 1e3 : 60) / 60));
16186
- const diff = holdMc - MC_COSTS.google_ads_transcribe * actualMins;
16187
- if (diff > 0) await creditMc(user.id, diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE_REFUND, "overestimate refund");
16183
+ const transcript = await runWithCostContext({ ...currentCostContext(), op: "google_ads_transcribe", userId: user.id }, () => transcribeMediaUrl(videoUrl, "# Google Ad Transcript"));
16184
+ const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
16185
+ const diff = holdMc - settleMc;
16186
+ if (diff > 0) await creditMc(user.id, diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE_REFUND, "real-cost reconcile");
16188
16187
  else if (diff < 0) await debitMc(user.id, -diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE, videoUrl);
16189
16188
  await logRequestEvent({ userId: user.id, source: "google_ads_transcribe", status: "done", query: videoUrl, resultCount: transcript.chunks.length, result: { text: transcript.text, chunks: transcript.chunks, durationMs: transcript.durationMs } });
16190
16189
  return c.json(transcript);
@@ -16852,10 +16851,15 @@ var init_instagram_routes = __esm({
16852
16851
  if (!audioUrl) {
16853
16852
  warnings.push("Transcript requested, but no audio/video track was available.");
16854
16853
  } else {
16855
- const tx = await debitMc(user.id, MC_COSTS.instagram_transcribe, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
16856
- if (!tx.ok) return c.json(insufficientBalanceResponse(tx.balance_mc, MC_COSTS.instagram_transcribe), 402);
16854
+ const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
16855
+ const tx = await debitMc(user.id, holdMc, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
16856
+ if (!tx.ok) return c.json(insufficientBalanceResponse(tx.balance_mc, holdMc), 402);
16857
16857
  transcriptDebited = true;
16858
16858
  transcript = await transcribeMediaUrl(audioUrl, "# Instagram Media Transcript");
16859
+ const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
16860
+ const diff = holdMc - settleMc;
16861
+ if (diff > 0) await creditMc(user.id, diff, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "real-cost reconcile");
16862
+ else if (diff < 0) await debitMc(user.id, -diff, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
16859
16863
  }
16860
16864
  }
16861
16865
  const result = {
@@ -16870,7 +16874,7 @@ var init_instagram_routes = __esm({
16870
16874
  return c.json(result);
16871
16875
  } catch (err) {
16872
16876
  const msg = err instanceof Error ? err.message : String(err);
16873
- if (transcriptDebited) await creditMc(user.id, MC_COSTS.instagram_transcribe, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "failed call");
16877
+ if (transcriptDebited) await creditMc(user.id, MEDIA_TRANSCRIBE_HOLD_MC, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "failed call");
16874
16878
  if (mediaDebited) await creditMc(user.id, MC_COSTS.instagram_media, LedgerOperation.INSTAGRAM_MEDIA_REFUND, "failed call");
16875
16879
  await logRequestEvent({ userId: user.id, source: "instagram_media_download", status: "failed", query: sourceUrl.href, error: msg });
16876
16880
  if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) return c.json({ error: msg }, 503);
@@ -17011,10 +17015,23 @@ var init_reddit_routes = __esm({
17011
17015
  result.comments = result.comments.slice(0, body.maxComments);
17012
17016
  result.numComments = result.comments.length;
17013
17017
  }
17018
+ const commentCount = Array.isArray(result.comments) ? result.comments.length : 0;
17019
+ commentDebitMc = commentCount * MC_COSTS.reddit_comment;
17020
+ if (commentDebitMc > 0) {
17021
+ const commentDebit = await debitMc(user.id, commentDebitMc, LedgerOperation.REDDIT_COMMENT, body.url);
17022
+ if (!commentDebit.ok) {
17023
+ const affordable = Math.floor((commentDebit.balance_mc ?? 0) / MC_COSTS.reddit_comment);
17024
+ result.comments = result.comments.slice(0, affordable);
17025
+ result.numComments = affordable;
17026
+ commentDebitMc = affordable * MC_COSTS.reddit_comment;
17027
+ if (commentDebitMc > 0) await debitMc(user.id, commentDebitMc, LedgerOperation.REDDIT_COMMENT, body.url);
17028
+ }
17029
+ }
17014
17030
  await logRequestEvent({ userId: user.id, source: "reddit_thread", status: "done", query: body.url, resultCount: Number(result.numComments ?? 0), result });
17015
17031
  return c.json(result);
17016
17032
  } catch (err) {
17017
17033
  if (debited && !refunded) await creditMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD_REFUND, "failed call");
17034
+ if (commentDebitMc > 0) await creditMc(user.id, commentDebitMc, LedgerOperation.REDDIT_COMMENT_REFUND, "failed call");
17018
17035
  const msg = err instanceof Error ? err.message : String(err);
17019
17036
  await logRequestEvent({ userId: user.id, source: "reddit_thread", status: "failed", query: body.url, error: msg });
17020
17037
  return c.json({ error: msg }, 500);
@@ -27287,7 +27304,7 @@ var PACKAGE_VERSION;
27287
27304
  var init_version = __esm({
27288
27305
  "src/version.ts"() {
27289
27306
  "use strict";
27290
- PACKAGE_VERSION = "0.4.12";
27307
+ PACKAGE_VERSION = "0.4.14";
27291
27308
  }
27292
27309
  });
27293
27310
 
@@ -38373,6 +38390,11 @@ function opForCostPath(p) {
38373
38390
  return "fb_ad";
38374
38391
  }
38375
38392
  if (p.startsWith("/instagram/")) return "instagram";
38393
+ if (p.startsWith("/google-ads/")) {
38394
+ if (p.endsWith("/transcribe")) return "google_ads_transcribe";
38395
+ if (p.endsWith("/search")) return "google_ads_search";
38396
+ return "google_ads_intel";
38397
+ }
38376
38398
  if (p === "/reddit/thread") return "reddit_thread";
38377
38399
  if (p.startsWith("/trustpilot/")) return "trustpilot_reviews";
38378
38400
  if (p.startsWith("/g2/")) return "g2_reviews";