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
@@ -11,12 +11,14 @@ import {
11
11
  currentCostContext,
12
12
  deleteKernelProxyId,
13
13
  harvest,
14
+ kernelCostUsd,
14
15
  migrateCostTelemetry,
15
16
  recordKernelSession,
16
17
  recordVendorUsage,
17
18
  resolveKernelProxyId,
18
- runWithCostContext
19
- } from "./chunk-2YY46QYT.js";
19
+ runWithCostContext,
20
+ vendorCostUsd
21
+ } from "./chunk-AUCXKRRH.js";
20
22
  import {
21
23
  HttpMcpToolExecutor,
22
24
  MemoryMcpToolExecutor,
@@ -31,7 +33,7 @@ import {
31
33
  registerSerpIntelligenceCaptureTools,
32
34
  sanitizeAttempts,
33
35
  sanitizeHarvestResult
34
- } from "./chunk-6EXP6DQG.js";
36
+ } from "./chunk-XZ2GPRCM.js";
35
37
  import {
36
38
  auditImages,
37
39
  getBlobStore
@@ -65,7 +67,7 @@ import {
65
67
  RawMapsOverviewSchema,
66
68
  RawMapsReviewStatsSchema
67
69
  } from "./chunk-XGIPATLV.js";
68
- import "./chunk-SHXJQQOH.js";
70
+ import "./chunk-OL65BO4S.js";
69
71
  import {
70
72
  completeExtractJob,
71
73
  countSuccessfulPages,
@@ -76,7 +78,7 @@ import {
76
78
  saveExtractPages,
77
79
  setExtractJobTotal,
78
80
  settleExtractJob
79
- } from "./chunk-ONIOF5XW.js";
81
+ } from "./chunk-VQV7MB2S.js";
80
82
  import {
81
83
  BROWSER_OPEN_MIN_BALANCE_MC,
82
84
  CONCURRENCY_PRICE_ID,
@@ -86,6 +88,8 @@ import {
86
88
  MC_COSTS,
87
89
  MC_PER_CREDIT,
88
90
  MC_PER_USD,
91
+ MEDIA_TRANSCRIBE_HOLD_MC,
92
+ MEDIA_TRANSCRIBE_MARGIN_MULTIPLE,
89
93
  MEMORY_AI_MARGIN_MULTIPLE,
90
94
  MEMORY_PLANS,
91
95
  MEMORY_PLAN_QUOTA,
@@ -97,7 +101,7 @@ import {
97
101
  browserActiveCostMc,
98
102
  concurrencySlotBillingInfo,
99
103
  insufficientBalanceResponse
100
- } from "./chunk-EMY7ELRU.js";
104
+ } from "./chunk-SIPXEFTR.js";
101
105
  import {
102
106
  CaptchaError,
103
107
  RECAPTCHA_INSTRUCTIONS,
@@ -8699,8 +8703,91 @@ async function ytHarvest(rawOptions) {
8699
8703
  );
8700
8704
  }
8701
8705
 
8702
- // src/youtube/CaptionFetcher.ts
8706
+ // src/youtube/audio-resolver.ts
8707
+ function mediaResolverUrl() {
8708
+ return process.env.FRAMES_SERVICE_URL?.trim() || void 0;
8709
+ }
8710
+ function mediaResolverSecret() {
8711
+ return process.env.FRAMES_SERVICE_SECRET?.trim() || void 0;
8712
+ }
8713
+ function mediaResolverConfigured() {
8714
+ return Boolean(mediaResolverUrl() && mediaResolverSecret());
8715
+ }
8716
+ var RESOLVE_TIMEOUT_MS = 24e4;
8717
+ async function resolveYouTubeAudio(videoId) {
8718
+ const base = mediaResolverUrl();
8719
+ const secret2 = mediaResolverSecret();
8720
+ if (!base || !secret2) return null;
8721
+ const controller = new AbortController();
8722
+ const timer = setTimeout(() => controller.abort(), RESOLVE_TIMEOUT_MS);
8723
+ try {
8724
+ const resp = await fetch(`${base.replace(/\/+$/, "")}/resolve-audio`, {
8725
+ method: "POST",
8726
+ headers: { "content-type": "application/json", "x-frames-secret": secret2 },
8727
+ body: JSON.stringify({ videoUrl: `https://www.youtube.com/watch?v=${videoId}` }),
8728
+ signal: controller.signal
8729
+ });
8730
+ if (!resp.ok) {
8731
+ const detail = await resp.text().catch(() => "");
8732
+ console.warn(`[audio-resolver] ${videoId}: http_${resp.status} ${detail.slice(0, 200)}`);
8733
+ return null;
8734
+ }
8735
+ const data = await resp.json();
8736
+ if (!data.mediaUrl) {
8737
+ console.warn(`[audio-resolver] ${videoId}: response missing mediaUrl`);
8738
+ return null;
8739
+ }
8740
+ return {
8741
+ mediaUrl: data.mediaUrl,
8742
+ durationS: Number(data.durationS) || 0,
8743
+ sizeBytes: Number(data.sizeBytes) || 0,
8744
+ mimeType: data.mimeType ?? "audio/mp4"
8745
+ };
8746
+ } catch (err) {
8747
+ const msg = err instanceof Error ? err.message : String(err);
8748
+ console.warn(`[audio-resolver] ${videoId}: resolve failed \u2014 ${msg}`);
8749
+ return null;
8750
+ } finally {
8751
+ clearTimeout(timer);
8752
+ }
8753
+ }
8754
+
8755
+ // src/services/media-transcription.ts
8703
8756
  import { fal } from "@fal-ai/client";
8757
+ function transcriptMarkdown(title, text, chunks, durationMs) {
8758
+ const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
8759
+ const lines = [title, "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
8760
+ if (chunks.length) {
8761
+ lines.push("## Timestamped Segments", "");
8762
+ for (const ch of chunks) {
8763
+ lines.push(`**[${fmtTs2(ch.timestamp[0])} -> ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
8764
+ }
8765
+ }
8766
+ return lines.join("\n");
8767
+ }
8768
+ async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript") {
8769
+ const startMs = Date.now();
8770
+ const result = await fal.subscribe("fal-ai/wizper", {
8771
+ input: { audio_url: mediaUrl, task: "transcribe", language: "en" },
8772
+ logs: false,
8773
+ pollInterval: 3e3
8774
+ });
8775
+ const data = result.data;
8776
+ const text = data.text ?? "";
8777
+ const chunks = data.chunks ?? [];
8778
+ const durationMs = Date.now() - startMs;
8779
+ void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: durationMs / 1e3, unitType: "compute_sec" });
8780
+ const costUsd = vendorCostUsd("fal_wizper", durationMs / 1e3);
8781
+ return {
8782
+ text,
8783
+ chunks,
8784
+ durationMs,
8785
+ costUsd,
8786
+ markdown: transcriptMarkdown(markdownTitle, text, chunks, durationMs)
8787
+ };
8788
+ }
8789
+
8790
+ // src/youtube/CaptionFetcher.ts
8704
8791
  async function fetchViaYoutubeTranscript(videoId) {
8705
8792
  try {
8706
8793
  const { YoutubeTranscript } = await import("youtube-transcript");
@@ -8773,13 +8860,12 @@ var INNERTUBE_CLIENTS = [
8773
8860
  { 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;)" },
8774
8861
  { name: "ANDROID_VR", version: "1.62.27", userAgent: "com.google.android.apps.youtube.vr.oculus/1.62.27 (Linux; U; Android 12L) gzip" }
8775
8862
  ];
8776
- function isBotBlockStatus(status) {
8777
- return status === "LOGIN_REQUIRED" || status === "AGE_VERIFICATION_REQUIRED";
8778
- }
8779
8863
  async function probePlayer(page, videoId) {
8780
8864
  return page.evaluate(
8781
8865
  async ({ vid, clients }) => {
8782
8866
  let lastStatus = null;
8867
+ let sawBotBlock = false;
8868
+ let sawAccessible = false;
8783
8869
  for (const client2 of clients) {
8784
8870
  const resp = await fetch("/youtubei/v1/player?prettyPrint=false", {
8785
8871
  method: "POST",
@@ -8795,98 +8881,24 @@ async function probePlayer(page, videoId) {
8795
8881
  }
8796
8882
  const data = await resp.json();
8797
8883
  lastStatus = data?.playabilityStatus?.status ?? null;
8798
- 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));
8799
- const pick = audioFormats[0] ?? null;
8800
- let captions = null;
8884
+ if (lastStatus === "LOGIN_REQUIRED" || lastStatus === "AGE_VERIFICATION_REQUIRED") sawBotBlock = true;
8885
+ else if (lastStatus === "OK") sawAccessible = true;
8801
8886
  const tracks = data?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
8802
8887
  if (tracks.length > 0) {
8803
8888
  const track = tracks.find((t) => t.languageCode === "en") ?? tracks[0];
8804
8889
  const sep = track.baseUrl.includes("?") ? "&" : "?";
8805
8890
  const xmlResp = await fetch(`${track.baseUrl}${sep}fmt=json3`).catch(() => null);
8806
- if (xmlResp?.ok) captions = await xmlResp.text();
8891
+ if (xmlResp?.ok) {
8892
+ const captions = await xmlResp.text();
8893
+ if (captions) return { captions, status: lastStatus, escalate: false };
8894
+ }
8807
8895
  }
8808
- if (!captions && !pick) continue;
8809
- return {
8810
- captions,
8811
- status: lastStatus,
8812
- audio: pick ? {
8813
- url: pick.url,
8814
- mimeType: pick.mimeType,
8815
- bitrate: pick.bitrate ?? null,
8816
- contentLength: pick.contentLength ? Number(pick.contentLength) || null : null,
8817
- approxDurationMs: pick.approxDurationMs ? Number(pick.approxDurationMs) || null : null
8818
- } : null
8819
- };
8820
8896
  }
8821
- return { captions: null, audio: null, status: lastStatus };
8897
+ return { captions: null, status: lastStatus, escalate: sawBotBlock && !sawAccessible };
8822
8898
  },
8823
8899
  { vid: videoId, clients: INNERTUBE_CLIENTS }
8824
8900
  ).catch(() => null);
8825
8901
  }
8826
- var MAX_AUDIO_BYTES = 96 * 1024 * 1024;
8827
- async function downloadWholeAudioStreamInOneRequestBecauseYouTubeAllowsOnlyOnePerIp(page, audio) {
8828
- if (audio.contentLength && audio.contentLength > MAX_AUDIO_BYTES) {
8829
- console.warn(`[CaptionFetcher] audio stream ${audio.contentLength} bytes exceeds ${MAX_AUDIO_BYTES} byte cap`);
8830
- return null;
8831
- }
8832
- const sep = audio.url.includes("?") ? "&" : "?";
8833
- const url = audio.contentLength ? `${audio.url}${sep}range=0-${audio.contentLength - 1}` : audio.url;
8834
- const result = await page.evaluate(async (u) => {
8835
- try {
8836
- const resp = await fetch(u);
8837
- if (!resp.ok) return { ok: false, error: `http_${resp.status}` };
8838
- const blob = await resp.blob();
8839
- const dataUrl = await new Promise((resolve, reject) => {
8840
- const reader = new FileReader();
8841
- reader.onload = () => resolve(reader.result);
8842
- reader.onerror = () => reject(reader.error);
8843
- reader.readAsDataURL(blob);
8844
- });
8845
- return { ok: true, base64: dataUrl.slice(dataUrl.indexOf(",") + 1) };
8846
- } catch (e) {
8847
- return { ok: false, error: String(e) };
8848
- }
8849
- }, url).catch((e) => ({ ok: false, error: String(e) }));
8850
- if (!result.ok || !result.base64) {
8851
- console.warn(`[CaptionFetcher] audio fetch failed (${result.error ?? "unknown"}) for a ${audio.contentLength ?? 0} byte stream`);
8852
- return null;
8853
- }
8854
- const bytes = Buffer.from(result.base64, "base64");
8855
- if (audio.contentLength && bytes.length < audio.contentLength) {
8856
- console.warn(`[CaptionFetcher] partial audio: ${bytes.length} of ${audio.contentLength} bytes`);
8857
- return null;
8858
- }
8859
- return { bytes, mimeType: audio.mimeType.split(";")[0].trim() };
8860
- }
8861
- async function transcribeWithWizper(videoId, audio, startMs) {
8862
- const falKey = process.env.FAL_KEY;
8863
- if (!falKey) return null;
8864
- try {
8865
- fal.config({ credentials: falKey });
8866
- const ext = audio.mimeType.includes("webm") ? "webm" : audio.mimeType.includes("mp4") ? "m4a" : "audio";
8867
- const audioFile = new File([new Uint8Array(audio.bytes)], `audio.${ext}`, { type: audio.mimeType });
8868
- const uploadedUrl = await fal.storage.upload(audioFile);
8869
- const subscribeStartMs = Date.now();
8870
- const result = await fal.subscribe("fal-ai/wizper", {
8871
- input: { audio_url: uploadedUrl, task: "transcribe", language: "en", chunk_level: "segment" },
8872
- logs: false,
8873
- pollInterval: 3e3
8874
- });
8875
- const subscribeDurationMs = Date.now() - subscribeStartMs;
8876
- const data = result.data;
8877
- const chunks = (data.chunks ?? []).map((c) => ({
8878
- timestamp: c.timestamp,
8879
- text: c.text.trim()
8880
- }));
8881
- void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: subscribeDurationMs / 1e3, unitType: "compute_sec" });
8882
- const text = data.text ?? chunks.map((c) => c.text).join(" ");
8883
- if (!text) return null;
8884
- return { videoId, text, chunks, durationMs: Date.now() - startMs, method: "browser-whisper" };
8885
- } catch (err) {
8886
- console.warn(`[CaptionFetcher] wizper transcription failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
8887
- return null;
8888
- }
8889
- }
8890
8902
  var YT_RESIDENTIAL_LOCATION = process.env.YOUTUBE_PROXY_LOCATION ?? "Austin, Texas";
8891
8903
  async function resolveResidentialProxy(kernelApiKey) {
8892
8904
  try {
@@ -8903,14 +8915,13 @@ async function resolveResidentialProxy(kernelApiKey) {
8903
8915
  return {};
8904
8916
  }
8905
8917
  }
8906
- async function probeAndExtract(videoId, opts) {
8918
+ async function probeCaptions(videoId, kernelProxyIdOverride) {
8907
8919
  const kernelApiKey = browserServiceApiKey();
8908
8920
  if (!kernelApiKey) return null;
8909
- const kernelProxyId = opts?.kernelProxyId ?? browserServiceProxyId();
8921
+ const kernelProxyId = kernelProxyIdOverride ?? browserServiceProxyId();
8910
8922
  const driver = new BrowserDriver();
8911
8923
  return runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: "browser-innertube" }, async () => {
8912
8924
  let entries = [];
8913
- let audio = null;
8914
8925
  let retryWithFreshIp = false;
8915
8926
  try {
8916
8927
  await driver.launch({ kernelApiKey, kernelProxyId, headless: true, headlessMode: "headless", viewport: { width: 1280, height: 800 }, locale: "en-US" });
@@ -8922,69 +8933,73 @@ async function probeAndExtract(videoId, opts) {
8922
8933
  );
8923
8934
  const probe = await probePlayer(page, videoId);
8924
8935
  if (!probe) return null;
8925
- if (probe.captions && !opts?.skipCaptions) {
8936
+ if (probe.captions) {
8926
8937
  entries = parseTimedtextJson3(probe.captions);
8927
8938
  if (entries.length === 0) entries = parseTimedtextXml(probe.captions);
8928
8939
  }
8929
8940
  if (entries.length === 0) {
8930
- if (!probe.audio) {
8931
- retryWithFreshIp = isBotBlockStatus(probe.status);
8932
- console.warn(`[CaptionFetcher] no captions and no audio stream for ${videoId} (playability=${probe.status ?? "unknown"}, proxied=${Boolean(kernelProxyId)})`);
8933
- return { entries, audio: null, retryWithFreshIp };
8934
- }
8935
- audio = await downloadWholeAudioStreamInOneRequestBecauseYouTubeAllowsOnlyOnePerIp(page, probe.audio);
8936
- if (!audio) return { entries, audio: null, retryWithFreshIp: true };
8941
+ retryWithFreshIp = probe.escalate;
8942
+ console.warn(`[CaptionFetcher] no caption track for ${videoId} (playability=${probe.status ?? "unknown"}, proxied=${Boolean(kernelProxyId)}, escalate=${probe.escalate})`);
8937
8943
  }
8938
8944
  } catch (err) {
8939
8945
  console.warn(`[CaptionFetcher] kernel session failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
8940
8946
  return null;
8941
8947
  } finally {
8942
- const servedSubOp = audio ? "browser-whisper" : "browser-innertube";
8943
- await runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: servedSubOp }, () => driver.close());
8948
+ await driver.close();
8944
8949
  }
8945
- return { entries, audio, retryWithFreshIp };
8950
+ return { entries, retryWithFreshIp };
8946
8951
  });
8947
8952
  }
8948
- async function extractWithProxyEscalation(videoId, opts) {
8949
- const direct = await probeAndExtract(videoId, opts);
8950
- if (direct && (direct.entries.length > 0 || direct.audio)) return direct;
8951
- const shouldEscalate = direct === null || direct.retryWithFreshIp;
8952
- if (!shouldEscalate) return direct;
8953
- const kernelApiKey = browserServiceApiKey();
8954
- if (!kernelApiKey) return direct;
8955
- const { kernelProxyId, disposableProxyId } = await resolveResidentialProxy(kernelApiKey);
8956
- if (!kernelProxyId) return direct;
8957
- try {
8958
- console.warn(`[CaptionFetcher] ${videoId}: retrying through residential proxy after bot block`);
8959
- const viaProxy = await probeAndExtract(videoId, { ...opts, kernelProxyId });
8960
- return viaProxy ?? direct;
8961
- } finally {
8962
- if (disposableProxyId) {
8963
- await deleteKernelProxyId(kernelApiKey, disposableProxyId).catch(
8964
- (err) => console.warn(`[CaptionFetcher] failed to delete disposable proxy: ${err instanceof Error ? err.message : String(err)}`)
8965
- );
8966
- }
8967
- }
8968
- }
8969
8953
  async function fetchViaKernelSession(videoId) {
8970
8954
  const start = Date.now();
8971
- const outcome = await extractWithProxyEscalation(videoId);
8972
- if (!outcome) return null;
8973
- if (outcome.entries.length > 0) {
8974
- const chunks = outcome.entries.map((e) => ({
8975
- timestamp: [e.start / 1e3, (e.start + e.dur) / 1e3],
8976
- text: e.text
8977
- }));
8978
- const text = chunks.map((c) => c.text).join(" ");
8979
- return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-innertube" };
8980
- }
8981
- if (outcome.audio) {
8982
- return runWithCostContext(
8983
- { ...currentCostContext(), op: "yt_transcription", subOp: "browser-whisper" },
8984
- () => transcribeWithWizper(videoId, outcome.audio, start)
8985
- );
8955
+ let outcome = await probeCaptions(videoId);
8956
+ if (!outcome || outcome.entries.length === 0 && outcome.retryWithFreshIp) {
8957
+ const kernelApiKey = browserServiceApiKey();
8958
+ if (kernelApiKey) {
8959
+ const { kernelProxyId, disposableProxyId } = await resolveResidentialProxy(kernelApiKey);
8960
+ if (kernelProxyId) {
8961
+ try {
8962
+ console.warn(`[CaptionFetcher] ${videoId}: retrying captions through residential proxy after bot block`);
8963
+ outcome = await probeCaptions(videoId, kernelProxyId) ?? outcome;
8964
+ } finally {
8965
+ if (disposableProxyId) {
8966
+ await deleteKernelProxyId(kernelApiKey, disposableProxyId).catch(
8967
+ (err) => console.warn(`[CaptionFetcher] failed to delete disposable proxy: ${err instanceof Error ? err.message : String(err)}`)
8968
+ );
8969
+ }
8970
+ }
8971
+ }
8972
+ }
8986
8973
  }
8987
- return null;
8974
+ if (!outcome || outcome.entries.length === 0) return null;
8975
+ const chunks = outcome.entries.map((e) => ({
8976
+ timestamp: [e.start / 1e3, (e.start + e.dur) / 1e3],
8977
+ text: e.text
8978
+ }));
8979
+ const text = chunks.map((c) => c.text).join(" ");
8980
+ return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-innertube" };
8981
+ }
8982
+ async function fetchViaResolvedAudio(videoId) {
8983
+ if (!mediaResolverConfigured()) return null;
8984
+ return runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: "audio-whisper" }, async () => {
8985
+ const resolved = await resolveYouTubeAudio(videoId);
8986
+ if (!resolved) return null;
8987
+ try {
8988
+ const transcript = await transcribeMediaUrl(resolved.mediaUrl, `# Transcript \u2014 ${videoId}`);
8989
+ if (!transcript.text) return null;
8990
+ const chunks = transcript.chunks.map((c) => ({ timestamp: c.timestamp, text: c.text.trim() }));
8991
+ return {
8992
+ videoId,
8993
+ text: transcript.text,
8994
+ chunks,
8995
+ durationMs: resolved.durationS > 0 ? Math.round(resolved.durationS * 1e3) : transcript.durationMs,
8996
+ method: "audio-whisper"
8997
+ };
8998
+ } catch (err) {
8999
+ console.warn(`[CaptionFetcher] transcription of resolved audio failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
9000
+ return null;
9001
+ }
9002
+ });
8988
9003
  }
8989
9004
  function finalizeCaptions(result) {
8990
9005
  const chunks = result.chunks.map((c) => {
@@ -9006,8 +9021,10 @@ async function fetchCaptions(videoId) {
9006
9021
  if (primary) return finalizeCaptions(primary);
9007
9022
  const result = await fetchViaKernelSession(videoId);
9008
9023
  if (result) return finalizeCaptions(result);
9024
+ const viaResolvedAudio = await fetchViaResolvedAudio(videoId);
9025
+ if (viaResolvedAudio) return finalizeCaptions(viaResolvedAudio);
9009
9026
  throw new Error(
9010
- `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.`
9027
+ `Could not transcribe ${videoId}: it has no caption track and its audio could not be retrieved.`
9011
9028
  );
9012
9029
  }
9013
9030
 
@@ -10373,42 +10390,7 @@ async function extractFacebookOrganicVideoFromPage(page, sourceUrl, quality = "b
10373
10390
  }
10374
10391
 
10375
10392
  // src/api/facebook-ad-routes.ts
10376
- import { fal as fal3 } from "@fal-ai/client";
10377
-
10378
- // src/services/media-transcription.ts
10379
10393
  import { fal as fal2 } from "@fal-ai/client";
10380
- function transcriptMarkdown(title, text, chunks, durationMs) {
10381
- const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
10382
- const lines = [title, "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
10383
- if (chunks.length) {
10384
- lines.push("## Timestamped Segments", "");
10385
- for (const ch of chunks) {
10386
- lines.push(`**[${fmtTs2(ch.timestamp[0])} -> ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
10387
- }
10388
- }
10389
- return lines.join("\n");
10390
- }
10391
- async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript") {
10392
- const startMs = Date.now();
10393
- const result = await fal2.subscribe("fal-ai/wizper", {
10394
- input: { audio_url: mediaUrl, task: "transcribe", language: "en" },
10395
- logs: false,
10396
- pollInterval: 3e3
10397
- });
10398
- const data = result.data;
10399
- const text = data.text ?? "";
10400
- const chunks = data.chunks ?? [];
10401
- const durationMs = Date.now() - startMs;
10402
- void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: durationMs / 1e3, unitType: "compute_sec" });
10403
- return {
10404
- text,
10405
- chunks,
10406
- durationMs,
10407
- markdown: transcriptMarkdown(markdownTitle, text, chunks, durationMs)
10408
- };
10409
- }
10410
-
10411
- // src/api/facebook-ad-routes.ts
10412
10394
  var FacebookAdBodySchema = z13.object({
10413
10395
  url: z13.string().trim().optional(),
10414
10396
  libraryId: z13.string().trim().optional(),
@@ -10588,17 +10570,17 @@ facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
10588
10570
  metadata: { videoUrl }
10589
10571
  });
10590
10572
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
10591
- fal3.config({ credentials: process.env.FAL_KEY });
10592
- const holdMc = MC_COSTS.fb_transcribe * 5;
10573
+ fal2.config({ credentials: process.env.FAL_KEY });
10574
+ const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
10593
10575
  let debited = false;
10594
10576
  try {
10595
10577
  const { ok, balance_mc } = await debitMc(fbUser.id, holdMc, LedgerOperation.FB_TRANSCRIBE, videoUrl);
10596
10578
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
10597
10579
  debited = true;
10598
10580
  const transcript = await transcribeFacebookVideoUrl(videoUrl, "# Facebook Ad Transcript");
10599
- const actualMins = Math.max(1, Math.ceil((Number.isFinite(transcript.durationMs) ? transcript.durationMs / 1e3 : 60) / 60));
10600
- const diff = holdMc - MC_COSTS.fb_transcribe * actualMins;
10601
- if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "overestimate refund");
10581
+ const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
10582
+ const diff = holdMc - settleMc;
10583
+ if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "real-cost reconcile");
10602
10584
  else if (diff < 0) await debitMc(fbUser.id, -diff, LedgerOperation.FB_TRANSCRIBE, videoUrl);
10603
10585
  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 } });
10604
10586
  return c.json(transcript);
@@ -10632,22 +10614,24 @@ facebookAdApp.post("/video-transcribe", createApiKeyAuth(), async (c) => {
10632
10614
  metadata: { url: sourceUrl.href }
10633
10615
  });
10634
10616
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
10635
- fal3.config({ credentials: process.env.FAL_KEY });
10617
+ fal2.config({ credentials: process.env.FAL_KEY });
10636
10618
  const driver = new BrowserDriver();
10637
- const holdMc = MC_COSTS.fb_transcribe * 5;
10619
+ const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
10638
10620
  let debited = false;
10639
10621
  try {
10640
10622
  const { ok, balance_mc } = await debitMc(fbUser.id, holdMc, LedgerOperation.FB_TRANSCRIBE, sourceUrl.href);
10641
10623
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
10642
10624
  debited = true;
10625
+ const kernelStartMs = Date.now();
10643
10626
  await driver.launch(await kernelLaunchOptsResidential());
10644
10627
  await driver.navigateTo(sourceUrl.href);
10645
10628
  const page = driver.getPage();
10646
10629
  const video = await extractFacebookOrganicVideoFromPage(page, sourceUrl.href, body.quality);
10630
+ const kernelMs = Date.now() - kernelStartMs;
10647
10631
  const transcript = await transcribeFacebookVideoUrl(video.videoUrl, "# Facebook Organic Video Transcript");
10648
- const actualMins = Math.max(1, Math.ceil((Number.isFinite(transcript.durationMs) ? transcript.durationMs / 1e3 : 60) / 60));
10649
- const diff = holdMc - MC_COSTS.fb_transcribe * actualMins;
10650
- if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "overestimate refund");
10632
+ const settleMc = Math.round((transcript.costUsd + kernelCostUsd(kernelMs, true)) * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
10633
+ const diff = holdMc - settleMc;
10634
+ if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "real-cost reconcile");
10651
10635
  else if (diff < 0) await debitMc(fbUser.id, -diff, LedgerOperation.FB_TRANSCRIBE, sourceUrl.href);
10652
10636
  const result = {
10653
10637
  ...video,
@@ -10983,7 +10967,7 @@ var GoogleAdsExtractor = class {
10983
10967
  };
10984
10968
 
10985
10969
  // src/api/google-ads-routes.ts
10986
- import { fal as fal4 } from "@fal-ai/client";
10970
+ import { fal as fal3 } from "@fal-ai/client";
10987
10971
  var GoogleAdsSearchBodySchema = z14.object({
10988
10972
  query: z14.string().trim().min(1, "query is required"),
10989
10973
  region: z14.string().trim().toUpperCase().length(2).optional(),
@@ -11039,9 +11023,11 @@ googleAdsApp.post("/search", createApiKeyAuth(), async (c) => {
11039
11023
  const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.google_ads_search, LedgerOperation.GOOGLE_ADS_SEARCH, body.query.trim());
11040
11024
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.google_ads_search), 402);
11041
11025
  debited = true;
11042
- await driver.launch(await kernelLaunchOptsResidential2());
11043
- const extractor = new GoogleAdsExtractor(driver);
11044
- const result = await extractor.search(body.query.trim(), region, maxResults);
11026
+ const result = await runWithCostContext({ ...currentCostContext(), op: "google_ads_search", userId: user.id }, async () => {
11027
+ await driver.launch(await kernelLaunchOptsResidential2());
11028
+ const extractor = new GoogleAdsExtractor(driver);
11029
+ return extractor.search(body.query.trim(), region, maxResults);
11030
+ });
11045
11031
  if (result.advertisers.length === 0) {
11046
11032
  await creditMc(user.id, MC_COSTS.google_ads_search, LedgerOperation.GOOGLE_ADS_SEARCH_REFUND, "empty result");
11047
11033
  refunded = true;
@@ -11077,9 +11063,11 @@ googleAdsApp.post("/page-intel", createApiKeyAuth(), async (c) => {
11077
11063
  const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.google_ads_intel, LedgerOperation.GOOGLE_ADS_INTEL, queryLabel);
11078
11064
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.google_ads_intel), 402);
11079
11065
  debited = true;
11080
- await driver.launch(await kernelLaunchOptsResidential2());
11081
- const extractor = new GoogleAdsExtractor(driver);
11082
- const result = await extractor.pageIntel({ advertiserId: body.advertiserId, domain: body.domain, region, maxAds });
11066
+ const result = await runWithCostContext({ ...currentCostContext(), op: "google_ads_intel", userId: user.id }, async () => {
11067
+ await driver.launch(await kernelLaunchOptsResidential2());
11068
+ const extractor = new GoogleAdsExtractor(driver);
11069
+ return extractor.pageIntel({ advertiserId: body.advertiserId, domain: body.domain, region, maxAds });
11070
+ });
11083
11071
  if (result.ads.length === 0) {
11084
11072
  await creditMc(user.id, MC_COSTS.google_ads_intel, LedgerOperation.GOOGLE_ADS_INTEL_REFUND, "empty result");
11085
11073
  refunded = true;
@@ -11109,17 +11097,17 @@ googleAdsApp.post("/transcribe", createApiKeyAuth(), async (c) => {
11109
11097
  const user = c.get("user");
11110
11098
  const gate = await acquireConcurrencyGate(user, "google_ads_transcribe", { reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"), metadata: { videoUrl } });
11111
11099
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
11112
- fal4.config({ credentials: process.env.FAL_KEY });
11113
- const holdMc = MC_COSTS.google_ads_transcribe * 5;
11100
+ fal3.config({ credentials: process.env.FAL_KEY });
11101
+ const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
11114
11102
  let debited = false;
11115
11103
  try {
11116
11104
  const { ok, balance_mc } = await debitMc(user.id, holdMc, LedgerOperation.GOOGLE_ADS_TRANSCRIBE, videoUrl);
11117
11105
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
11118
11106
  debited = true;
11119
- const transcript = await transcribeMediaUrl(videoUrl, "# Google Ad Transcript");
11120
- const actualMins = Math.max(1, Math.ceil((Number.isFinite(transcript.durationMs) ? transcript.durationMs / 1e3 : 60) / 60));
11121
- const diff = holdMc - MC_COSTS.google_ads_transcribe * actualMins;
11122
- if (diff > 0) await creditMc(user.id, diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE_REFUND, "overestimate refund");
11107
+ const transcript = await runWithCostContext({ ...currentCostContext(), op: "google_ads_transcribe", userId: user.id }, () => transcribeMediaUrl(videoUrl, "# Google Ad Transcript"));
11108
+ const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
11109
+ const diff = holdMc - settleMc;
11110
+ if (diff > 0) await creditMc(user.id, diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE_REFUND, "real-cost reconcile");
11123
11111
  else if (diff < 0) await debitMc(user.id, -diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE, videoUrl);
11124
11112
  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 } });
11125
11113
  return c.json(transcript);
@@ -11768,10 +11756,15 @@ instagramApp.post("/media-download", createApiKeyAuth(), async (c) => {
11768
11756
  if (!audioUrl) {
11769
11757
  warnings.push("Transcript requested, but no audio/video track was available.");
11770
11758
  } else {
11771
- const tx = await debitMc(user.id, MC_COSTS.instagram_transcribe, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
11772
- if (!tx.ok) return c.json(insufficientBalanceResponse(tx.balance_mc, MC_COSTS.instagram_transcribe), 402);
11759
+ const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
11760
+ const tx = await debitMc(user.id, holdMc, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
11761
+ if (!tx.ok) return c.json(insufficientBalanceResponse(tx.balance_mc, holdMc), 402);
11773
11762
  transcriptDebited = true;
11774
11763
  transcript = await transcribeMediaUrl(audioUrl, "# Instagram Media Transcript");
11764
+ const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
11765
+ const diff = holdMc - settleMc;
11766
+ if (diff > 0) await creditMc(user.id, diff, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "real-cost reconcile");
11767
+ else if (diff < 0) await debitMc(user.id, -diff, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
11775
11768
  }
11776
11769
  }
11777
11770
  const result = {
@@ -11786,7 +11779,7 @@ instagramApp.post("/media-download", createApiKeyAuth(), async (c) => {
11786
11779
  return c.json(result);
11787
11780
  } catch (err) {
11788
11781
  const msg = err instanceof Error ? err.message : String(err);
11789
- if (transcriptDebited) await creditMc(user.id, MC_COSTS.instagram_transcribe, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "failed call");
11782
+ if (transcriptDebited) await creditMc(user.id, MEDIA_TRANSCRIBE_HOLD_MC, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "failed call");
11790
11783
  if (mediaDebited) await creditMc(user.id, MC_COSTS.instagram_media, LedgerOperation.INSTAGRAM_MEDIA_REFUND, "failed call");
11791
11784
  await logRequestEvent({ userId: user.id, source: "instagram_media_download", status: "failed", query: sourceUrl.href, error: msg });
11792
11785
  if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) return c.json({ error: msg }, 503);
@@ -11914,10 +11907,23 @@ redditApp.post("/thread", createApiKeyAuth(), async (c) => {
11914
11907
  result.comments = result.comments.slice(0, body.maxComments);
11915
11908
  result.numComments = result.comments.length;
11916
11909
  }
11910
+ const commentCount = Array.isArray(result.comments) ? result.comments.length : 0;
11911
+ commentDebitMc = commentCount * MC_COSTS.reddit_comment;
11912
+ if (commentDebitMc > 0) {
11913
+ const commentDebit = await debitMc(user.id, commentDebitMc, LedgerOperation.REDDIT_COMMENT, body.url);
11914
+ if (!commentDebit.ok) {
11915
+ const affordable = Math.floor((commentDebit.balance_mc ?? 0) / MC_COSTS.reddit_comment);
11916
+ result.comments = result.comments.slice(0, affordable);
11917
+ result.numComments = affordable;
11918
+ commentDebitMc = affordable * MC_COSTS.reddit_comment;
11919
+ if (commentDebitMc > 0) await debitMc(user.id, commentDebitMc, LedgerOperation.REDDIT_COMMENT, body.url);
11920
+ }
11921
+ }
11917
11922
  await logRequestEvent({ userId: user.id, source: "reddit_thread", status: "done", query: body.url, resultCount: Number(result.numComments ?? 0), result });
11918
11923
  return c.json(result);
11919
11924
  } catch (err) {
11920
11925
  if (debited && !refunded) await creditMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD_REFUND, "failed call");
11926
+ if (commentDebitMc > 0) await creditMc(user.id, commentDebitMc, LedgerOperation.REDDIT_COMMENT_REFUND, "failed call");
11921
11927
  const msg = err instanceof Error ? err.message : String(err);
11922
11928
  await logRequestEvent({ userId: user.id, source: "reddit_thread", status: "failed", query: body.url, error: msg });
11923
11929
  return c.json({ error: msg }, 500);
@@ -20408,6 +20414,11 @@ function opForCostPath(p) {
20408
20414
  return "fb_ad";
20409
20415
  }
20410
20416
  if (p.startsWith("/instagram/")) return "instagram";
20417
+ if (p.startsWith("/google-ads/")) {
20418
+ if (p.endsWith("/transcribe")) return "google_ads_transcribe";
20419
+ if (p.endsWith("/search")) return "google_ads_search";
20420
+ return "google_ads_intel";
20421
+ }
20411
20422
  if (p === "/reddit/thread") return "reddit_thread";
20412
20423
  if (p.startsWith("/trustpilot/")) return "trustpilot_reviews";
20413
20424
  if (p.startsWith("/g2/")) return "g2_reviews";
@@ -21818,7 +21829,7 @@ app.get("/cron/tick", async (c) => {
21818
21829
  if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
21819
21830
  return c.json({ error: "Unauthorized" }, 401);
21820
21831
  }
21821
- const { drainQueue } = await import("./worker-HTYZAYGB.js");
21832
+ const { drainQueue } = await import("./worker-WCSKUAOT.js");
21822
21833
  const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
21823
21834
  const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
21824
21835
  const [results, sweepResult, reapResult, expiredResult, blobCleanup] = await Promise.all([
@@ -21836,7 +21847,7 @@ app.post("/api/internal/extract-refinalize/:id", async (c) => {
21836
21847
  return c.json({ error: "Unauthorized" }, 401);
21837
21848
  }
21838
21849
  const jobId = c.req.param("id");
21839
- const { getExtractJob: getExtractJob2, completeExtractJob: completeExtractJob2 } = await import("./site-extract-repository-HTIY52MW.js");
21850
+ const { getExtractJob: getExtractJob2, completeExtractJob: completeExtractJob2 } = await import("./site-extract-repository-6X2C7RLF.js");
21840
21851
  const { assembleExtractArtifacts } = await import("./extract-bundle-COS56ZDO.js");
21841
21852
  const job = await getExtractJob2(jobId);
21842
21853
  if (!job) return c.json({ error: "job not found" }, 404);
@@ -21844,7 +21855,7 @@ app.post("/api/internal/extract-refinalize/:id", async (c) => {
21844
21855
  await completeExtractJob2(jobId, stored);
21845
21856
  let settlement = "already_settled_or_refunded";
21846
21857
  if (job.billedMc == null && job.userId != null) {
21847
- const { settleExtractJob: settleExtractJob2, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-HTIY52MW.js");
21858
+ const { settleExtractJob: settleExtractJob2, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-6X2C7RLF.js");
21848
21859
  const heldMc = Number(job.options.heldMc ?? 0);
21849
21860
  const successful = await countSuccessfulPages2(jobId);
21850
21861
  const usedMc = Math.min(successful * MC_COSTS.page_scrape, heldMc);
@@ -22008,4 +22019,4 @@ app.get("/blog/:slug/", (c) => {
22008
22019
  export {
22009
22020
  app
22010
22021
  };
22011
- //# sourceMappingURL=server-UNID3SJU.js.map
22022
+ //# sourceMappingURL=server-TGORO4AJ.js.map