mcp-scraper 0.4.6 → 0.4.13

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 (42) hide show
  1. package/dist/bin/api-server.cjs +2446 -2148
  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.cjs +13 -4
  14. package/dist/bin/paa-harvest.cjs.map +1 -1
  15. package/dist/bin/paa-harvest.js +1 -1
  16. package/dist/{chunk-5GAD2AN2.js → chunk-AUCXKRRH.js} +29 -10
  17. package/dist/chunk-AUCXKRRH.js.map +1 -0
  18. package/dist/{chunk-IFXACD4K.js → chunk-EA2AANFI.js} +2 -2
  19. package/dist/chunk-O2D5HGKF.js +7 -0
  20. package/dist/chunk-O2D5HGKF.js.map +1 -0
  21. package/dist/{chunk-IVQYNY45.js → chunk-SIPXEFTR.js} +36 -15
  22. package/dist/chunk-SIPXEFTR.js.map +1 -0
  23. package/dist/{chunk-NYAWN7CZ.js → chunk-VQV7MB2S.js} +2 -2
  24. package/dist/index.cjs +59 -4
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.js +8 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/{server-R3KVQNOT.js → server-HNZG36IP.js} +576 -333
  29. package/dist/server-HNZG36IP.js.map +1 -0
  30. package/dist/{site-extract-repository-LI2S3S6E.js → site-extract-repository-6X2C7RLF.js} +3 -3
  31. package/dist/{worker-VASAJ7FZ.js → worker-WCSKUAOT.js} +16 -12
  32. package/dist/worker-WCSKUAOT.js.map +1 -0
  33. package/package.json +1 -2
  34. package/dist/chunk-5BB6Y3BB.js +0 -7
  35. package/dist/chunk-5BB6Y3BB.js.map +0 -1
  36. package/dist/chunk-5GAD2AN2.js.map +0 -1
  37. package/dist/chunk-IVQYNY45.js.map +0 -1
  38. package/dist/server-R3KVQNOT.js.map +0 -1
  39. package/dist/worker-VASAJ7FZ.js.map +0 -1
  40. /package/dist/{chunk-IFXACD4K.js.map → chunk-EA2AANFI.js.map} +0 -0
  41. /package/dist/{chunk-NYAWN7CZ.js.map → chunk-VQV7MB2S.js.map} +0 -0
  42. /package/dist/{site-extract-repository-LI2S3S6E.js.map → site-extract-repository-6X2C7RLF.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-5GAD2AN2.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-IFXACD4K.js";
36
+ } from "./chunk-EA2AANFI.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-5BB6Y3BB.js";
70
+ import "./chunk-O2D5HGKF.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-NYAWN7CZ.js";
81
+ } from "./chunk-VQV7MB2S.js";
80
82
  import {
81
83
  BROWSER_OPEN_MIN_BALANCE_MC,
82
84
  CONCURRENCY_PRICE_ID,
@@ -86,16 +88,20 @@ 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,
92
96
  SCHEDULING_PLANS,
97
+ SITE_AUDIT_LLM_MARGIN_MULTIPLE,
93
98
  SUBSCRIPTION_TIERS,
94
99
  SUBSCRIPTION_TIER_BY_KEY,
100
+ VIDEO_ANALYSIS_LLM_MARGIN_MULTIPLE,
95
101
  browserActiveCostMc,
96
102
  concurrencySlotBillingInfo,
97
103
  insufficientBalanceResponse
98
- } from "./chunk-IVQYNY45.js";
104
+ } from "./chunk-SIPXEFTR.js";
99
105
  import {
100
106
  CaptchaError,
101
107
  RECAPTCHA_INSTRUCTIONS,
@@ -5339,6 +5345,7 @@ function extractJson(raw) {
5339
5345
  var DeepInfraLlmClient = class {
5340
5346
  apiKey;
5341
5347
  model;
5348
+ totalCostUsd = 0;
5342
5349
  constructor(apiKey, model = "Qwen/Qwen3.6-35B-A3B") {
5343
5350
  this.apiKey = apiKey;
5344
5351
  this.model = model;
@@ -5371,6 +5378,7 @@ ${raw}`
5371
5378
  var OpenRouterLlmClient = class {
5372
5379
  apiKey;
5373
5380
  model;
5381
+ totalCostUsd = 0;
5374
5382
  constructor(apiKey, model = "qwen/qwen3-235b-a22b") {
5375
5383
  this.apiKey = apiKey;
5376
5384
  this.model = model;
@@ -5383,6 +5391,9 @@ var OpenRouterLlmClient = class {
5383
5391
  });
5384
5392
  if (!resp.ok) throw new Error(`OpenRouter error ${resp.status}: ${await resp.text()}`);
5385
5393
  const data = await resp.json();
5394
+ const cost = data.usage?.cost ?? 0;
5395
+ this.totalCostUsd += cost;
5396
+ void recordVendorUsage({ vendor: "openrouter", model: this.model, units: cost, unitType: "usd" });
5386
5397
  return data.choices[0].message.content;
5387
5398
  }
5388
5399
  async completeJson(prompt) {
@@ -5408,6 +5419,9 @@ var FallbackLlmClient = class {
5408
5419
  secondary;
5409
5420
  lastServedBy = null;
5410
5421
  lastPrimaryError = null;
5422
+ get totalCostUsd() {
5423
+ return this.primary.totalCostUsd + this.secondary.totalCostUsd;
5424
+ }
5411
5425
  async complete(prompt) {
5412
5426
  try {
5413
5427
  const result = await this.primary.complete(prompt);
@@ -6894,8 +6908,23 @@ var SiteAuditService = class {
6894
6908
  async getJobsForUser(userId) {
6895
6909
  return this.deps.repo.listSiteAuditJobs(userId);
6896
6910
  }
6911
+ async runLlmBilled(userId, source, fn) {
6912
+ const before = this.deps.llm.totalCostUsd;
6913
+ const result = await runWithCostContext({ ...currentCostContext(), op: "audit_site", userId }, fn);
6914
+ const deltaCostUsd = this.deps.llm.totalCostUsd - before;
6915
+ if (deltaCostUsd <= 0) return result;
6916
+ const mc = Math.round(deltaCostUsd * SITE_AUDIT_LLM_MARGIN_MULTIPLE * MC_PER_USD);
6917
+ if (mc <= 0) return result;
6918
+ const { ok } = await debitMc(userId, mc, LedgerOperation.SITE_AUDIT_LLM, source);
6919
+ if (!ok) {
6920
+ console.warn(`[site-audit-service] SITE_AUDIT_LLM surcharge debit failed for user ${userId} (${source}): insufficient balance for ${mc}mc`);
6921
+ }
6922
+ return result;
6923
+ }
6897
6924
  async runIngestPhase(jobId, payload) {
6898
6925
  const { request } = payload;
6926
+ const job = await this.deps.repo.getSiteAuditJob(jobId);
6927
+ if (!job) throw new Error(`[site-audit-service] runIngestPhase: job ${jobId} not found`);
6899
6928
  const input = {
6900
6929
  clientDomain: request.clientDomain,
6901
6930
  sfInternalPath: request.sfInternalPath,
@@ -6910,14 +6939,18 @@ var SiteAuditService = class {
6910
6939
  businessContext: request.businessContext,
6911
6940
  priorAuditMemoryEnabled: request.priorAuditMemoryEnabled
6912
6941
  };
6913
- const result = await llmParseWithRetry(
6914
- ValidatedFileManifestSchema,
6915
- (attempt, error) => {
6916
- void attempt;
6917
- void error;
6918
- return buildIngestValidatePrompt(input);
6919
- },
6920
- this.deps.llm
6942
+ const result = await this.runLlmBilled(
6943
+ job.user_id,
6944
+ `${jobId}:phase1-ingest`,
6945
+ () => llmParseWithRetry(
6946
+ ValidatedFileManifestSchema,
6947
+ (attempt, error) => {
6948
+ void attempt;
6949
+ void error;
6950
+ return buildIngestValidatePrompt(input);
6951
+ },
6952
+ this.deps.llm
6953
+ )
6921
6954
  );
6922
6955
  await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase1-ingest", result);
6923
6956
  }
@@ -6932,23 +6965,27 @@ var SiteAuditService = class {
6932
6965
  const competitorDomainsCsvPath = request.competitorDomainsCsvPath;
6933
6966
  void competitorDomainsCsvPath;
6934
6967
  const competitors = await this._readCompetitorDomains(request);
6935
- const results = await Promise.all(
6936
- competitors.map(
6937
- (domain, idx) => limit(async () => {
6938
- const competitorIndex = idx + 1;
6939
- const rawPrompt = buildEnrichCompetitorPrompt({ domain, sessionPath });
6940
- const prompt = rawPrompt.replace("<competitor-index>", String(competitorIndex));
6941
- const result = await llmParseWithRetry(
6942
- EnrichCompetitorOutputSchema,
6943
- (attempt, error) => {
6944
- void attempt;
6945
- void error;
6946
- return prompt;
6947
- },
6948
- this.deps.llm
6949
- );
6950
- return result;
6951
- })
6968
+ const results = await this.runLlmBilled(
6969
+ job.user_id,
6970
+ `${jobId}:phase1-enrich`,
6971
+ () => Promise.all(
6972
+ competitors.map(
6973
+ (domain, idx) => limit(async () => {
6974
+ const competitorIndex = idx + 1;
6975
+ const rawPrompt = buildEnrichCompetitorPrompt({ domain, sessionPath });
6976
+ const prompt = rawPrompt.replace("<competitor-index>", String(competitorIndex));
6977
+ const result = await llmParseWithRetry(
6978
+ EnrichCompetitorOutputSchema,
6979
+ (attempt, error) => {
6980
+ void attempt;
6981
+ void error;
6982
+ return prompt;
6983
+ },
6984
+ this.deps.llm
6985
+ );
6986
+ return result;
6987
+ })
6988
+ )
6952
6989
  )
6953
6990
  );
6954
6991
  await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase1-ingest", { competitors: results });
@@ -6962,19 +6999,23 @@ var SiteAuditService = class {
6962
6999
  const clientDomain = request.clientDomain;
6963
7000
  const urlInventory = [];
6964
7001
  const directedEdgeCount = 0;
6965
- const orphanAnnotation = await llmParseWithRetry(
6966
- OrphanAnnotationOutputSchema,
6967
- (attempt, error) => {
6968
- void attempt;
6969
- void error;
6970
- return buildGraphPerSitePrompt({
6971
- siteId: "client",
6972
- domain: clientDomain,
6973
- urlInventory,
6974
- directedEdgeCount
6975
- });
6976
- },
6977
- this.deps.llm
7002
+ const orphanAnnotation = await this.runLlmBilled(
7003
+ job.user_id,
7004
+ `${jobId}:phase2-build-graph`,
7005
+ () => llmParseWithRetry(
7006
+ OrphanAnnotationOutputSchema,
7007
+ (attempt, error) => {
7008
+ void attempt;
7009
+ void error;
7010
+ return buildGraphPerSitePrompt({
7011
+ siteId: "client",
7012
+ domain: clientDomain,
7013
+ urlInventory,
7014
+ directedEdgeCount
7015
+ });
7016
+ },
7017
+ this.deps.llm
7018
+ )
6978
7019
  );
6979
7020
  const graphMetrics = await this.deps.python.runGraphMetrics({
6980
7021
  directedLinkGraphPath: `${sessionPath}/phase1/directed_link_graph.json`,
@@ -7019,50 +7060,52 @@ var SiteAuditService = class {
7019
7060
  }
7020
7061
  if (currentBatch.length > 0) batches.push(currentBatch);
7021
7062
  const usZipsCsvPath = process.env["US_ZIPS_CSV_PATH"] ?? "/Users/vilovieta/Downloads/sales-magician-api-leads-magician-01c6cff78e31/tools/analytics/data/uszips.csv";
7022
- for (const batch of batches) {
7023
- let classified = [];
7024
- let unclassifiedUrls = batch.map((u) => u.url);
7025
- try {
7026
- const pythonResult = await this.deps.python.runLocationClassifier({
7027
- urls: batch.map((u) => u.url),
7028
- usZipsCsvPath
7029
- });
7030
- classified = pythonResult.classified;
7031
- unclassifiedUrls = pythonResult.unclassified;
7032
- } catch (err) {
7033
- console.warn(
7034
- "[site-audit-service] runClassifyPhase: python.runLocationClassifier failed (falling back to LLM):",
7035
- err instanceof Error ? err.message : String(err)
7036
- );
7037
- unclassifiedUrls = batch.map((u) => u.url);
7038
- }
7039
- for (const c of classified) {
7040
- allClassifications.push({
7041
- url: c.url,
7042
- page_type: c.pageType,
7043
- confidence: c.confidence > 0.6 ? "high" : c.confidence > 0.4 ? "medium" : "low",
7044
- _classification_label: "[COMPUTED:python/location-classifier]",
7045
- topic_cluster: null,
7046
- location_signals: c.locationSignals
7047
- });
7048
- }
7049
- if (unclassifiedUrls.length > 0) {
7050
- const unclassifiedBatch = batch.filter((u) => unclassifiedUrls.includes(u.url));
7051
- const llmResults = await llmParseWithRetry(
7052
- z9.array(PageTypeClassificationRowSchema),
7053
- (attempt, error) => {
7054
- void attempt;
7055
- void error;
7056
- return buildMetricsClassifyPrompt({
7057
- urls: unclassifiedBatch,
7058
- siteDomain: clientDomain
7059
- });
7060
- },
7061
- this.deps.llm
7062
- );
7063
- allClassifications.push(...llmResults);
7063
+ await this.runLlmBilled(job.user_id, `${jobId}:phase3-classify`, async () => {
7064
+ for (const batch of batches) {
7065
+ let classified = [];
7066
+ let unclassifiedUrls = batch.map((u) => u.url);
7067
+ try {
7068
+ const pythonResult = await this.deps.python.runLocationClassifier({
7069
+ urls: batch.map((u) => u.url),
7070
+ usZipsCsvPath
7071
+ });
7072
+ classified = pythonResult.classified;
7073
+ unclassifiedUrls = pythonResult.unclassified;
7074
+ } catch (err) {
7075
+ console.warn(
7076
+ "[site-audit-service] runClassifyPhase: python.runLocationClassifier failed (falling back to LLM):",
7077
+ err instanceof Error ? err.message : String(err)
7078
+ );
7079
+ unclassifiedUrls = batch.map((u) => u.url);
7080
+ }
7081
+ for (const c of classified) {
7082
+ allClassifications.push({
7083
+ url: c.url,
7084
+ page_type: c.pageType,
7085
+ confidence: c.confidence > 0.6 ? "high" : c.confidence > 0.4 ? "medium" : "low",
7086
+ _classification_label: "[COMPUTED:python/location-classifier]",
7087
+ topic_cluster: null,
7088
+ location_signals: c.locationSignals
7089
+ });
7090
+ }
7091
+ if (unclassifiedUrls.length > 0) {
7092
+ const unclassifiedBatch = batch.filter((u) => unclassifiedUrls.includes(u.url));
7093
+ const llmResults = await llmParseWithRetry(
7094
+ z9.array(PageTypeClassificationRowSchema),
7095
+ (attempt, error) => {
7096
+ void attempt;
7097
+ void error;
7098
+ return buildMetricsClassifyPrompt({
7099
+ urls: unclassifiedBatch,
7100
+ siteDomain: clientDomain
7101
+ });
7102
+ },
7103
+ this.deps.llm
7104
+ );
7105
+ allClassifications.push(...llmResults);
7106
+ }
7064
7107
  }
7065
- }
7108
+ });
7066
7109
  void sessionPath;
7067
7110
  await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase3-classify", {
7068
7111
  classifications: allClassifications,
@@ -7075,20 +7118,24 @@ var SiteAuditService = class {
7075
7118
  if (!job) throw new Error(`[site-audit-service] runComparePhase: job ${jobId} not found`);
7076
7119
  const request = JSON.parse(job.request);
7077
7120
  const clientDomain = request.clientDomain;
7078
- const result = await llmParseWithRetry(
7079
- CompareRecommendOutputSchema,
7080
- (attempt, error) => {
7081
- void attempt;
7082
- void error;
7083
- return buildCompareRecommendPrompt({
7084
- clientDomain,
7085
- clientMoneyPages: [],
7086
- competitorSites: [],
7087
- clientUrlMetrics: [],
7088
- clientPageTypes: []
7089
- });
7090
- },
7091
- this.deps.llm
7121
+ const result = await this.runLlmBilled(
7122
+ job.user_id,
7123
+ `${jobId}:phase4-compare`,
7124
+ () => llmParseWithRetry(
7125
+ CompareRecommendOutputSchema,
7126
+ (attempt, error) => {
7127
+ void attempt;
7128
+ void error;
7129
+ return buildCompareRecommendPrompt({
7130
+ clientDomain,
7131
+ clientMoneyPages: [],
7132
+ competitorSites: [],
7133
+ clientUrlMetrics: [],
7134
+ clientPageTypes: []
7135
+ });
7136
+ },
7137
+ this.deps.llm
7138
+ )
7092
7139
  );
7093
7140
  await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase4-compare", result);
7094
7141
  return result;
@@ -7118,20 +7165,24 @@ var SiteAuditService = class {
7118
7165
  total: ilesTotal,
7119
7166
  components: ilesComponents
7120
7167
  };
7121
- const reportMarkdown = await llmParseWithRetry(
7122
- z9.string(),
7123
- (attempt, error) => {
7124
- void attempt;
7125
- void error;
7126
- return buildScoreSynthesizePrompt({
7127
- preComputedArchitectureScore,
7128
- preComputedIlesScore,
7129
- rankedBacklog: [],
7130
- clientDomain,
7131
- sessionPath
7132
- });
7133
- },
7134
- this.deps.llm
7168
+ const reportMarkdown = await this.runLlmBilled(
7169
+ job.user_id,
7170
+ `${jobId}:phase5-synthesize`,
7171
+ () => llmParseWithRetry(
7172
+ z9.string(),
7173
+ (attempt, error) => {
7174
+ void attempt;
7175
+ void error;
7176
+ return buildScoreSynthesizePrompt({
7177
+ preComputedArchitectureScore,
7178
+ preComputedIlesScore,
7179
+ rankedBacklog: [],
7180
+ clientDomain,
7181
+ sessionPath
7182
+ });
7183
+ },
7184
+ this.deps.llm
7185
+ )
7135
7186
  );
7136
7187
  const auditResult = SiteAuditResultSchema.parse({
7137
7188
  jobId,
@@ -8140,6 +8191,11 @@ internalMemoryApp.post("/ai-debit", async (c) => {
8140
8191
  }
8141
8192
  const user = await getUserByEmail(body.identity.trim().toLowerCase());
8142
8193
  if (!user) return c.json({ ok: false, reason: "no_user" }, 200);
8194
+ if (body.costUsd > 0) {
8195
+ await runWithCostContext({ ...currentCostContext(), op: "memory_ai", userId: user.id }, async () => {
8196
+ void recordVendorUsage({ vendor: "mcp_memory_ai", model: null, units: body.costUsd, unitType: "usd", error: null });
8197
+ });
8198
+ }
8143
8199
  const mc = Math.round(body.costUsd * MEMORY_AI_MARGIN_MULTIPLE * MC_PER_USD);
8144
8200
  if (mc <= 0) return c.json({ ok: true, mc: 0, balance_mc: null }, 200);
8145
8201
  const { ok, balance_mc } = await debitMc(user.id, mc, LedgerOperation.MEMORY_AI, `${body.source ?? "ai"}:${body.op ?? ""}`);
@@ -8647,8 +8703,92 @@ async function ytHarvest(rawOptions) {
8647
8703
  );
8648
8704
  }
8649
8705
 
8650
- // 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
8651
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
8791
+ import { fal as fal2 } from "@fal-ai/client";
8652
8792
  async function fetchViaYoutubeTranscript(videoId) {
8653
8793
  try {
8654
8794
  const { YoutubeTranscript } = await import("youtube-transcript");
@@ -8660,6 +8800,11 @@ async function fetchViaYoutubeTranscript(videoId) {
8660
8800
  }));
8661
8801
  const text = chunks.map((c) => c.text).join(" ");
8662
8802
  const last = chunks[chunks.length - 1];
8803
+ const now = Date.now();
8804
+ await runWithCostContext(
8805
+ { ...currentCostContext(), op: "yt_transcription", subOp: "youtube-transcript" },
8806
+ () => recordKernelSession({ kernelSessionId: null, source: "yt_caption_tier", headlessSent: null, openedAtMs: now, closedAtMs: now })
8807
+ );
8663
8808
  return { videoId, text, chunks, durationMs: last ? last.timestamp[1] * 1e3 : 0, method: "youtube-transcript" };
8664
8809
  } catch {
8665
8810
  return null;
@@ -8711,166 +8856,246 @@ function parseTimedtextXml(xml) {
8711
8856
  }
8712
8857
  return results;
8713
8858
  }
8714
- async function fetchViaKernelInnertube(videoId) {
8715
- const kernelApiKey = browserServiceApiKey();
8716
- if (!kernelApiKey) return null;
8717
- const kernelProxyId = browserServiceProxyId();
8718
- const driver = new BrowserDriver();
8719
- const start = Date.now();
8720
- try {
8721
- await driver.launch({ kernelApiKey, kernelProxyId, headless: true, viewport: { width: 1280, height: 800 }, locale: "en-US" });
8722
- const page = driver.getPage();
8723
- await driver.navigateTo(`https://www.youtube.com/watch?v=${videoId}`);
8724
- await page.waitForFunction(
8725
- () => !!window.ytcfg,
8726
- { timeout: 2e4 }
8727
- );
8728
- const xml = await page.evaluate(async (vid) => {
8729
- const ANDROID_VERSION = "20.10.38";
8730
- const resp = await fetch("/youtubei/v1/player?prettyPrint=false", {
8731
- method: "POST",
8732
- headers: {
8733
- "Content-Type": "application/json",
8734
- "User-Agent": `com.google.android.youtube/${ANDROID_VERSION} (Linux; U; Android 14)`
8735
- },
8736
- body: JSON.stringify({
8737
- context: { client: { clientName: "ANDROID", clientVersion: ANDROID_VERSION } },
8738
- videoId: vid
8739
- })
8859
+ var INNERTUBE_CLIENTS = [
8860
+ { name: "ANDROID", version: "20.10.38", userAgent: "com.google.android.youtube/20.10.38 (Linux; U; Android 14)" },
8861
+ { 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;)" },
8862
+ { name: "ANDROID_VR", version: "1.62.27", userAgent: "com.google.android.apps.youtube.vr.oculus/1.62.27 (Linux; U; Android 12L) gzip" }
8863
+ ];
8864
+ function isBotBlockStatus(status) {
8865
+ return status === "LOGIN_REQUIRED" || status === "AGE_VERIFICATION_REQUIRED";
8866
+ }
8867
+ async function probePlayer(page, videoId) {
8868
+ return page.evaluate(
8869
+ async ({ vid, clients }) => {
8870
+ let lastStatus = null;
8871
+ for (const client2 of clients) {
8872
+ const resp = await fetch("/youtubei/v1/player?prettyPrint=false", {
8873
+ method: "POST",
8874
+ headers: { "Content-Type": "application/json", "User-Agent": client2.userAgent },
8875
+ body: JSON.stringify({
8876
+ context: { client: { clientName: client2.name, clientVersion: client2.version } },
8877
+ videoId: vid
8878
+ })
8879
+ }).catch(() => null);
8880
+ if (!resp?.ok) {
8881
+ lastStatus = resp ? `player_http_${resp.status}` : "player_fetch_failed";
8882
+ continue;
8883
+ }
8884
+ const data = await resp.json();
8885
+ lastStatus = data?.playabilityStatus?.status ?? null;
8886
+ 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));
8887
+ const pick = audioFormats[0] ?? null;
8888
+ let captions = null;
8889
+ const tracks = data?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
8890
+ if (tracks.length > 0) {
8891
+ const track = tracks.find((t) => t.languageCode === "en") ?? tracks[0];
8892
+ const sep = track.baseUrl.includes("?") ? "&" : "?";
8893
+ const xmlResp = await fetch(`${track.baseUrl}${sep}fmt=json3`).catch(() => null);
8894
+ if (xmlResp?.ok) captions = await xmlResp.text();
8895
+ }
8896
+ if (!captions && !pick) continue;
8897
+ return {
8898
+ captions,
8899
+ status: lastStatus,
8900
+ audio: pick ? {
8901
+ url: pick.url,
8902
+ mimeType: pick.mimeType,
8903
+ bitrate: pick.bitrate ?? null,
8904
+ contentLength: pick.contentLength ? Number(pick.contentLength) || null : null,
8905
+ approxDurationMs: pick.approxDurationMs ? Number(pick.approxDurationMs) || null : null
8906
+ } : null
8907
+ };
8908
+ }
8909
+ return { captions: null, audio: null, status: lastStatus };
8910
+ },
8911
+ { vid: videoId, clients: INNERTUBE_CLIENTS }
8912
+ ).catch(() => null);
8913
+ }
8914
+ var MAX_AUDIO_BYTES = 96 * 1024 * 1024;
8915
+ async function downloadWholeAudioStreamInOneRequestBecauseYouTubeAllowsOnlyOnePerIp(page, audio) {
8916
+ if (audio.contentLength && audio.contentLength > MAX_AUDIO_BYTES) {
8917
+ console.warn(`[CaptionFetcher] audio stream ${audio.contentLength} bytes exceeds ${MAX_AUDIO_BYTES} byte cap`);
8918
+ return null;
8919
+ }
8920
+ const sep = audio.url.includes("?") ? "&" : "?";
8921
+ const url = audio.contentLength ? `${audio.url}${sep}range=0-${audio.contentLength - 1}` : audio.url;
8922
+ const result = await page.evaluate(async (u) => {
8923
+ try {
8924
+ const resp = await fetch(u);
8925
+ if (!resp.ok) return { ok: false, error: `http_${resp.status}` };
8926
+ const blob = await resp.blob();
8927
+ const dataUrl = await new Promise((resolve, reject) => {
8928
+ const reader = new FileReader();
8929
+ reader.onload = () => resolve(reader.result);
8930
+ reader.onerror = () => reject(reader.error);
8931
+ reader.readAsDataURL(blob);
8740
8932
  });
8741
- if (!resp.ok) return null;
8742
- const data = await resp.json();
8743
- const tracks = data?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
8744
- if (tracks.length === 0) return null;
8745
- const track = tracks.find((t) => t.languageCode === "en") ?? tracks[0];
8746
- const sep = track.baseUrl.includes("?") ? "&" : "?";
8747
- const xmlResp = await fetch(`${track.baseUrl}${sep}fmt=json3`);
8748
- return xmlResp.ok ? xmlResp.text() : null;
8749
- }, videoId);
8750
- if (!xml) return null;
8751
- let entries = parseTimedtextJson3(xml);
8752
- if (entries.length === 0) entries = parseTimedtextXml(xml);
8753
- if (entries.length === 0) return null;
8754
- const chunks = entries.map((e) => ({
8755
- timestamp: [e.start / 1e3, (e.start + e.dur) / 1e3],
8756
- text: e.text
8757
- }));
8758
- const text = chunks.map((c) => c.text).join(" ");
8759
- const last = chunks[chunks.length - 1];
8760
- return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-innertube" };
8761
- } catch {
8933
+ return { ok: true, base64: dataUrl.slice(dataUrl.indexOf(",") + 1) };
8934
+ } catch (e) {
8935
+ return { ok: false, error: String(e) };
8936
+ }
8937
+ }, url).catch((e) => ({ ok: false, error: String(e) }));
8938
+ if (!result.ok || !result.base64) {
8939
+ console.warn(`[CaptionFetcher] audio fetch failed (${result.error ?? "unknown"}) for a ${audio.contentLength ?? 0} byte stream`);
8762
8940
  return null;
8763
- } finally {
8764
- await driver.close();
8765
8941
  }
8942
+ const bytes = Buffer.from(result.base64, "base64");
8943
+ if (audio.contentLength && bytes.length < audio.contentLength) {
8944
+ console.warn(`[CaptionFetcher] partial audio: ${bytes.length} of ${audio.contentLength} bytes`);
8945
+ return null;
8946
+ }
8947
+ return { bytes, mimeType: audio.mimeType.split(";")[0].trim() };
8766
8948
  }
8767
- var WHISPER_RECORD_SECONDS = 90;
8768
- async function captureAudioViaMediaRecorder(page) {
8769
- const result = await page.evaluate((recordSecs) => {
8770
- return new Promise((resolve) => {
8771
- const video = document.querySelector("video");
8772
- if (!video) {
8773
- resolve({ ok: false, error: "no video element" });
8774
- return;
8775
- }
8776
- video.muted = false;
8777
- video.volume = 1;
8778
- try {
8779
- const ctx = new AudioContext();
8780
- const src = ctx.createMediaElementSource(video);
8781
- const dest = ctx.createMediaStreamDestination();
8782
- src.connect(dest);
8783
- src.connect(ctx.destination);
8784
- const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "audio/ogg";
8785
- const recorder = new MediaRecorder(dest.stream, { mimeType, audioBitsPerSecond: 48e3 });
8786
- const chunks = [];
8787
- recorder.ondataavailable = (e) => {
8788
- if (e.data.size > 0) chunks.push(e.data);
8789
- };
8790
- recorder.onstop = async () => {
8791
- const blob = new Blob(chunks, { type: mimeType });
8792
- const ab = await blob.arrayBuffer();
8793
- const arr = new Uint8Array(ab);
8794
- let binary = "";
8795
- const chunkSize = 8192;
8796
- for (let i = 0; i < arr.length; i += chunkSize) {
8797
- binary += String.fromCharCode(...arr.slice(i, Math.min(i + chunkSize, arr.length)));
8798
- }
8799
- resolve({ ok: true, base64: btoa(binary), size: ab.byteLength, mimeType });
8800
- };
8801
- recorder.start(1e3);
8802
- video.playbackRate = 2;
8803
- video.play().catch(() => void 0);
8804
- setTimeout(() => {
8805
- recorder.stop();
8806
- ctx.close();
8807
- }, recordSecs * 1e3);
8808
- } catch (e) {
8809
- resolve({ ok: false, error: String(e) });
8810
- }
8811
- });
8812
- }, WHISPER_RECORD_SECONDS);
8813
- if (!result.ok || !result.base64 || !result.mimeType) return null;
8814
- return { bytes: Buffer.from(result.base64, "base64"), mimeType: result.mimeType };
8815
- }
8816
- async function attemptKernelWhisper(videoId, kernelApiKey, kernelProxyId, falKey, start) {
8817
- const driver = new BrowserDriver();
8949
+ async function transcribeWithWizper(videoId, audio, startMs) {
8950
+ const falKey = process.env.FAL_KEY;
8951
+ if (!falKey) return null;
8818
8952
  try {
8819
- await driver.launch({ kernelApiKey, kernelProxyId, headless: true, viewport: { width: 1280, height: 800 }, locale: "en-US" });
8820
- const page = driver.getPage();
8821
- await driver.navigateTo(`https://www.youtube.com/watch?v=${videoId}`);
8822
- await page.waitForFunction(
8823
- () => !!window.ytcfg,
8824
- { timeout: 2e4 }
8825
- );
8826
- const playStatus = await page.evaluate(() => {
8827
- const w = window;
8828
- return w.ytInitialPlayerResponse?.playabilityStatus?.status;
8829
- });
8830
- if (playStatus !== "OK") {
8831
- await driver.close();
8832
- return null;
8833
- }
8834
- const audioInfo = await captureAudioViaMediaRecorder(page);
8835
- await driver.close();
8836
- if (!audioInfo || audioInfo.bytes.length < 1e4) return null;
8837
- fal.config({ credentials: falKey });
8838
- const ext = audioInfo.mimeType.includes("ogg") ? "ogg" : "webm";
8839
- const audioFile = new File([new Uint8Array(audioInfo.bytes)], `audio.${ext}`, { type: audioInfo.mimeType });
8840
- const uploadedUrl = await fal.storage.upload(audioFile);
8841
- const result = await fal.subscribe("fal-ai/wizper", {
8842
- input: { audio_url: uploadedUrl, task: "transcribe", language: "en" },
8953
+ fal2.config({ credentials: falKey });
8954
+ const ext = audio.mimeType.includes("webm") ? "webm" : audio.mimeType.includes("mp4") ? "m4a" : "audio";
8955
+ const audioFile = new File([new Uint8Array(audio.bytes)], `audio.${ext}`, { type: audio.mimeType });
8956
+ const uploadedUrl = await fal2.storage.upload(audioFile);
8957
+ const subscribeStartMs = Date.now();
8958
+ const result = await fal2.subscribe("fal-ai/wizper", {
8959
+ input: { audio_url: uploadedUrl, task: "transcribe", language: "en", chunk_level: "segment" },
8843
8960
  logs: false,
8844
8961
  pollInterval: 3e3
8845
8962
  });
8963
+ const subscribeDurationMs = Date.now() - subscribeStartMs;
8846
8964
  const data = result.data;
8847
8965
  const chunks = (data.chunks ?? []).map((c) => ({
8848
8966
  timestamp: c.timestamp,
8849
8967
  text: c.text.trim()
8850
8968
  }));
8851
- const audioSec = chunks.length ? chunks[chunks.length - 1]?.timestamp?.[1] ?? 0 : 0;
8852
- void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: audioSec / 60, unitType: "audio_min" });
8969
+ void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: subscribeDurationMs / 1e3, unitType: "compute_sec" });
8853
8970
  const text = data.text ?? chunks.map((c) => c.text).join(" ");
8854
8971
  if (!text) return null;
8855
- return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-whisper" };
8856
- } catch {
8857
- await driver.close();
8972
+ return { videoId, text, chunks, durationMs: Date.now() - startMs, method: "browser-whisper" };
8973
+ } catch (err) {
8974
+ console.warn(`[CaptionFetcher] wizper transcription failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
8858
8975
  return null;
8859
8976
  }
8860
8977
  }
8861
- async function fetchViaKernelWhisper(videoId) {
8978
+ var YT_RESIDENTIAL_LOCATION = process.env.YOUTUBE_PROXY_LOCATION ?? "Austin, Texas";
8979
+ async function resolveResidentialProxy(kernelApiKey) {
8980
+ try {
8981
+ const resolved = await resolveKernelProxyId({
8982
+ kernelApiKey,
8983
+ proxyMode: "location",
8984
+ location: YT_RESIDENTIAL_LOCATION,
8985
+ gl: "us",
8986
+ fresh: true
8987
+ });
8988
+ return { kernelProxyId: resolved.kernelProxyId, disposableProxyId: resolved.disposableProxyId };
8989
+ } catch (err) {
8990
+ console.warn(`[CaptionFetcher] residential proxy resolution failed: ${err instanceof Error ? err.message : String(err)}`);
8991
+ return {};
8992
+ }
8993
+ }
8994
+ async function probeAndExtract(videoId, opts) {
8862
8995
  const kernelApiKey = browserServiceApiKey();
8863
- const kernelProxyId = browserServiceProxyId();
8864
- const falKey = process.env.FAL_KEY;
8865
- if (!kernelApiKey || !falKey) return null;
8996
+ if (!kernelApiKey) return null;
8997
+ const kernelProxyId = opts?.kernelProxyId ?? browserServiceProxyId();
8998
+ const driver = new BrowserDriver();
8999
+ return runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: "browser-innertube" }, async () => {
9000
+ let entries = [];
9001
+ let audio = null;
9002
+ let retryWithFreshIp = false;
9003
+ try {
9004
+ await driver.launch({ kernelApiKey, kernelProxyId, headless: true, headlessMode: "headless", viewport: { width: 1280, height: 800 }, locale: "en-US" });
9005
+ const page = driver.getPage();
9006
+ await driver.navigateTo(`https://www.youtube.com/watch?v=${videoId}`);
9007
+ await page.waitForFunction(
9008
+ () => !!window.ytcfg,
9009
+ { timeout: 2e4 }
9010
+ );
9011
+ const probe = await probePlayer(page, videoId);
9012
+ if (!probe) return null;
9013
+ if (probe.captions && !opts?.skipCaptions) {
9014
+ entries = parseTimedtextJson3(probe.captions);
9015
+ if (entries.length === 0) entries = parseTimedtextXml(probe.captions);
9016
+ }
9017
+ if (entries.length === 0) {
9018
+ if (!probe.audio) {
9019
+ retryWithFreshIp = isBotBlockStatus(probe.status);
9020
+ console.warn(`[CaptionFetcher] no captions and no audio stream for ${videoId} (playability=${probe.status ?? "unknown"}, proxied=${Boolean(kernelProxyId)})`);
9021
+ return { entries, audio: null, retryWithFreshIp };
9022
+ }
9023
+ audio = await downloadWholeAudioStreamInOneRequestBecauseYouTubeAllowsOnlyOnePerIp(page, probe.audio);
9024
+ if (!audio) return { entries, audio: null, retryWithFreshIp: true };
9025
+ }
9026
+ } catch (err) {
9027
+ console.warn(`[CaptionFetcher] kernel session failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
9028
+ return null;
9029
+ } finally {
9030
+ const servedSubOp = audio ? "browser-whisper" : "browser-innertube";
9031
+ await runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: servedSubOp }, () => driver.close());
9032
+ }
9033
+ return { entries, audio, retryWithFreshIp };
9034
+ });
9035
+ }
9036
+ async function extractWithProxyEscalation(videoId, opts) {
9037
+ const direct = await probeAndExtract(videoId, opts);
9038
+ if (direct && (direct.entries.length > 0 || direct.audio)) return direct;
9039
+ const shouldEscalate = direct === null || direct.retryWithFreshIp;
9040
+ if (!shouldEscalate) return direct;
9041
+ const kernelApiKey = browserServiceApiKey();
9042
+ if (!kernelApiKey) return direct;
9043
+ const { kernelProxyId, disposableProxyId } = await resolveResidentialProxy(kernelApiKey);
9044
+ if (!kernelProxyId) return direct;
9045
+ try {
9046
+ console.warn(`[CaptionFetcher] ${videoId}: retrying through residential proxy after bot block`);
9047
+ const viaProxy = await probeAndExtract(videoId, { ...opts, kernelProxyId });
9048
+ return viaProxy ?? direct;
9049
+ } finally {
9050
+ if (disposableProxyId) {
9051
+ await deleteKernelProxyId(kernelApiKey, disposableProxyId).catch(
9052
+ (err) => console.warn(`[CaptionFetcher] failed to delete disposable proxy: ${err instanceof Error ? err.message : String(err)}`)
9053
+ );
9054
+ }
9055
+ }
9056
+ }
9057
+ async function fetchViaKernelSession(videoId) {
8866
9058
  const start = Date.now();
8867
- for (let attempt = 0; attempt < 4; attempt++) {
8868
- if (attempt > 0) await new Promise((r) => setTimeout(r, 2e3 * attempt));
8869
- const result = await attemptKernelWhisper(videoId, kernelApiKey, kernelProxyId, falKey, start);
8870
- if (result) return result;
9059
+ const outcome = await extractWithProxyEscalation(videoId);
9060
+ if (!outcome) return null;
9061
+ if (outcome.entries.length > 0) {
9062
+ const chunks = outcome.entries.map((e) => ({
9063
+ timestamp: [e.start / 1e3, (e.start + e.dur) / 1e3],
9064
+ text: e.text
9065
+ }));
9066
+ const text = chunks.map((c) => c.text).join(" ");
9067
+ return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-innertube" };
9068
+ }
9069
+ if (outcome.audio) {
9070
+ return runWithCostContext(
9071
+ { ...currentCostContext(), op: "yt_transcription", subOp: "browser-whisper" },
9072
+ () => transcribeWithWizper(videoId, outcome.audio, start)
9073
+ );
8871
9074
  }
8872
9075
  return null;
8873
9076
  }
9077
+ async function fetchViaResolvedAudio(videoId) {
9078
+ if (!mediaResolverConfigured()) return null;
9079
+ return runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: "audio-whisper" }, async () => {
9080
+ const resolved = await resolveYouTubeAudio(videoId);
9081
+ if (!resolved) return null;
9082
+ try {
9083
+ const transcript = await transcribeMediaUrl(resolved.mediaUrl, `# Transcript \u2014 ${videoId}`);
9084
+ if (!transcript.text) return null;
9085
+ const chunks = transcript.chunks.map((c) => ({ timestamp: c.timestamp, text: c.text.trim() }));
9086
+ return {
9087
+ videoId,
9088
+ text: transcript.text,
9089
+ chunks,
9090
+ durationMs: resolved.durationS > 0 ? Math.round(resolved.durationS * 1e3) : transcript.durationMs,
9091
+ method: "audio-whisper"
9092
+ };
9093
+ } catch (err) {
9094
+ console.warn(`[CaptionFetcher] transcription of resolved audio failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
9095
+ return null;
9096
+ }
9097
+ });
9098
+ }
8874
9099
  function finalizeCaptions(result) {
8875
9100
  const chunks = result.chunks.map((c) => {
8876
9101
  const startOk = Number.isFinite(c.timestamp[0]);
@@ -8889,11 +9114,13 @@ function finalizeCaptions(result) {
8889
9114
  async function fetchCaptions(videoId) {
8890
9115
  const primary = await fetchViaYoutubeTranscript(videoId);
8891
9116
  if (primary) return finalizeCaptions(primary);
8892
- const kernel = await fetchViaKernelInnertube(videoId);
8893
- if (kernel) return finalizeCaptions(kernel);
8894
- const whisper = await fetchViaKernelWhisper(videoId);
8895
- if (whisper) return finalizeCaptions(whisper);
8896
- throw new Error(`No captions available for ${videoId} \u2014 all three tiers failed`);
9117
+ const result = await fetchViaKernelSession(videoId);
9118
+ if (result) return finalizeCaptions(result);
9119
+ const viaResolvedAudio = await fetchViaResolvedAudio(videoId);
9120
+ if (viaResolvedAudio) return finalizeCaptions(viaResolvedAudio);
9121
+ throw new Error(
9122
+ `Could not transcribe ${videoId}: it has no caption track and its audio could not be retrieved.`
9123
+ );
8897
9124
  }
8898
9125
 
8899
9126
  // src/api/api-auth.ts
@@ -9221,7 +9448,7 @@ youtubeApp.post("/transcribe", createApiKeyAuth(), async (c) => {
9221
9448
  const { ok: trOk, balance_mc: trBal } = await debitMc(user.id, holdMc, LedgerOperation.TRANSCRIPTION_HOLD, id);
9222
9449
  if (!trOk) return c.json(insufficientBalanceResponse(trBal, holdMc), 402);
9223
9450
  debited = true;
9224
- const result = await fetchCaptions(id);
9451
+ const result = await runWithCostContext({ ...currentCostContext(), op: "yt_transcription", userId: user.id }, () => fetchCaptions(id));
9225
9452
  const lastChunk = result.chunks?.[result.chunks.length - 1];
9226
9453
  const chunkSecs = lastChunk && Number.isFinite(lastChunk.timestamp[1]) ? lastChunk.timestamp[1] : 0;
9227
9454
  const durationSecs = Number.isFinite(result.durationMs) ? result.durationMs / 1e3 : 0;
@@ -10259,42 +10486,6 @@ async function extractFacebookOrganicVideoFromPage(page, sourceUrl, quality = "b
10259
10486
 
10260
10487
  // src/api/facebook-ad-routes.ts
10261
10488
  import { fal as fal3 } from "@fal-ai/client";
10262
-
10263
- // src/services/media-transcription.ts
10264
- import { fal as fal2 } from "@fal-ai/client";
10265
- function transcriptMarkdown(title, text, chunks, durationMs) {
10266
- const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
10267
- const lines = [title, "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
10268
- if (chunks.length) {
10269
- lines.push("## Timestamped Segments", "");
10270
- for (const ch of chunks) {
10271
- lines.push(`**[${fmtTs2(ch.timestamp[0])} -> ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
10272
- }
10273
- }
10274
- return lines.join("\n");
10275
- }
10276
- async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript") {
10277
- const startMs = Date.now();
10278
- const result = await fal2.subscribe("fal-ai/wizper", {
10279
- input: { audio_url: mediaUrl, task: "transcribe", language: "en" },
10280
- logs: false,
10281
- pollInterval: 3e3
10282
- });
10283
- const data = result.data;
10284
- const text = data.text ?? "";
10285
- const chunks = data.chunks ?? [];
10286
- const durationMs = Date.now() - startMs;
10287
- const audioSec = chunks.length ? chunks[chunks.length - 1]?.timestamp?.[1] ?? 0 : 0;
10288
- void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: audioSec / 60, unitType: "audio_min" });
10289
- return {
10290
- text,
10291
- chunks,
10292
- durationMs,
10293
- markdown: transcriptMarkdown(markdownTitle, text, chunks, durationMs)
10294
- };
10295
- }
10296
-
10297
- // src/api/facebook-ad-routes.ts
10298
10489
  var FacebookAdBodySchema = z13.object({
10299
10490
  url: z13.string().trim().optional(),
10300
10491
  libraryId: z13.string().trim().optional(),
@@ -10475,16 +10666,16 @@ facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
10475
10666
  });
10476
10667
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
10477
10668
  fal3.config({ credentials: process.env.FAL_KEY });
10478
- const holdMc = MC_COSTS.fb_transcribe * 5;
10669
+ const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
10479
10670
  let debited = false;
10480
10671
  try {
10481
10672
  const { ok, balance_mc } = await debitMc(fbUser.id, holdMc, LedgerOperation.FB_TRANSCRIBE, videoUrl);
10482
10673
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
10483
10674
  debited = true;
10484
10675
  const transcript = await transcribeFacebookVideoUrl(videoUrl, "# Facebook Ad Transcript");
10485
- const actualMins = Math.max(1, Math.ceil((Number.isFinite(transcript.durationMs) ? transcript.durationMs / 1e3 : 60) / 60));
10486
- const diff = holdMc - MC_COSTS.fb_transcribe * actualMins;
10487
- if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "overestimate refund");
10676
+ const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
10677
+ const diff = holdMc - settleMc;
10678
+ if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "real-cost reconcile");
10488
10679
  else if (diff < 0) await debitMc(fbUser.id, -diff, LedgerOperation.FB_TRANSCRIBE, videoUrl);
10489
10680
  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 } });
10490
10681
  return c.json(transcript);
@@ -10520,20 +10711,22 @@ facebookAdApp.post("/video-transcribe", createApiKeyAuth(), async (c) => {
10520
10711
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
10521
10712
  fal3.config({ credentials: process.env.FAL_KEY });
10522
10713
  const driver = new BrowserDriver();
10523
- const holdMc = MC_COSTS.fb_transcribe * 5;
10714
+ const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
10524
10715
  let debited = false;
10525
10716
  try {
10526
10717
  const { ok, balance_mc } = await debitMc(fbUser.id, holdMc, LedgerOperation.FB_TRANSCRIBE, sourceUrl.href);
10527
10718
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
10528
10719
  debited = true;
10720
+ const kernelStartMs = Date.now();
10529
10721
  await driver.launch(await kernelLaunchOptsResidential());
10530
10722
  await driver.navigateTo(sourceUrl.href);
10531
10723
  const page = driver.getPage();
10532
10724
  const video = await extractFacebookOrganicVideoFromPage(page, sourceUrl.href, body.quality);
10725
+ const kernelMs = Date.now() - kernelStartMs;
10533
10726
  const transcript = await transcribeFacebookVideoUrl(video.videoUrl, "# Facebook Organic Video Transcript");
10534
- const actualMins = Math.max(1, Math.ceil((Number.isFinite(transcript.durationMs) ? transcript.durationMs / 1e3 : 60) / 60));
10535
- const diff = holdMc - MC_COSTS.fb_transcribe * actualMins;
10536
- if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "overestimate refund");
10727
+ const settleMc = Math.round((transcript.costUsd + kernelCostUsd(kernelMs, true)) * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
10728
+ const diff = holdMc - settleMc;
10729
+ if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "real-cost reconcile");
10537
10730
  else if (diff < 0) await debitMc(fbUser.id, -diff, LedgerOperation.FB_TRANSCRIBE, sourceUrl.href);
10538
10731
  const result = {
10539
10732
  ...video,
@@ -10925,9 +11118,11 @@ googleAdsApp.post("/search", createApiKeyAuth(), async (c) => {
10925
11118
  const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.google_ads_search, LedgerOperation.GOOGLE_ADS_SEARCH, body.query.trim());
10926
11119
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.google_ads_search), 402);
10927
11120
  debited = true;
10928
- await driver.launch(await kernelLaunchOptsResidential2());
10929
- const extractor = new GoogleAdsExtractor(driver);
10930
- const result = await extractor.search(body.query.trim(), region, maxResults);
11121
+ const result = await runWithCostContext({ ...currentCostContext(), op: "google_ads_search", userId: user.id }, async () => {
11122
+ await driver.launch(await kernelLaunchOptsResidential2());
11123
+ const extractor = new GoogleAdsExtractor(driver);
11124
+ return extractor.search(body.query.trim(), region, maxResults);
11125
+ });
10931
11126
  if (result.advertisers.length === 0) {
10932
11127
  await creditMc(user.id, MC_COSTS.google_ads_search, LedgerOperation.GOOGLE_ADS_SEARCH_REFUND, "empty result");
10933
11128
  refunded = true;
@@ -10963,9 +11158,11 @@ googleAdsApp.post("/page-intel", createApiKeyAuth(), async (c) => {
10963
11158
  const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.google_ads_intel, LedgerOperation.GOOGLE_ADS_INTEL, queryLabel);
10964
11159
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.google_ads_intel), 402);
10965
11160
  debited = true;
10966
- await driver.launch(await kernelLaunchOptsResidential2());
10967
- const extractor = new GoogleAdsExtractor(driver);
10968
- const result = await extractor.pageIntel({ advertiserId: body.advertiserId, domain: body.domain, region, maxAds });
11161
+ const result = await runWithCostContext({ ...currentCostContext(), op: "google_ads_intel", userId: user.id }, async () => {
11162
+ await driver.launch(await kernelLaunchOptsResidential2());
11163
+ const extractor = new GoogleAdsExtractor(driver);
11164
+ return extractor.pageIntel({ advertiserId: body.advertiserId, domain: body.domain, region, maxAds });
11165
+ });
10969
11166
  if (result.ads.length === 0) {
10970
11167
  await creditMc(user.id, MC_COSTS.google_ads_intel, LedgerOperation.GOOGLE_ADS_INTEL_REFUND, "empty result");
10971
11168
  refunded = true;
@@ -10996,16 +11193,16 @@ googleAdsApp.post("/transcribe", createApiKeyAuth(), async (c) => {
10996
11193
  const gate = await acquireConcurrencyGate(user, "google_ads_transcribe", { reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"), metadata: { videoUrl } });
10997
11194
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
10998
11195
  fal4.config({ credentials: process.env.FAL_KEY });
10999
- const holdMc = MC_COSTS.google_ads_transcribe * 5;
11196
+ const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
11000
11197
  let debited = false;
11001
11198
  try {
11002
11199
  const { ok, balance_mc } = await debitMc(user.id, holdMc, LedgerOperation.GOOGLE_ADS_TRANSCRIBE, videoUrl);
11003
11200
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
11004
11201
  debited = true;
11005
- const transcript = await transcribeMediaUrl(videoUrl, "# Google Ad Transcript");
11006
- const actualMins = Math.max(1, Math.ceil((Number.isFinite(transcript.durationMs) ? transcript.durationMs / 1e3 : 60) / 60));
11007
- const diff = holdMc - MC_COSTS.google_ads_transcribe * actualMins;
11008
- if (diff > 0) await creditMc(user.id, diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE_REFUND, "overestimate refund");
11202
+ const transcript = await runWithCostContext({ ...currentCostContext(), op: "google_ads_transcribe", userId: user.id }, () => transcribeMediaUrl(videoUrl, "# Google Ad Transcript"));
11203
+ const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
11204
+ const diff = holdMc - settleMc;
11205
+ if (diff > 0) await creditMc(user.id, diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE_REFUND, "real-cost reconcile");
11009
11206
  else if (diff < 0) await debitMc(user.id, -diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE, videoUrl);
11010
11207
  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 } });
11011
11208
  return c.json(transcript);
@@ -11654,10 +11851,15 @@ instagramApp.post("/media-download", createApiKeyAuth(), async (c) => {
11654
11851
  if (!audioUrl) {
11655
11852
  warnings.push("Transcript requested, but no audio/video track was available.");
11656
11853
  } else {
11657
- const tx = await debitMc(user.id, MC_COSTS.instagram_transcribe, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
11658
- if (!tx.ok) return c.json(insufficientBalanceResponse(tx.balance_mc, MC_COSTS.instagram_transcribe), 402);
11854
+ const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
11855
+ const tx = await debitMc(user.id, holdMc, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
11856
+ if (!tx.ok) return c.json(insufficientBalanceResponse(tx.balance_mc, holdMc), 402);
11659
11857
  transcriptDebited = true;
11660
11858
  transcript = await transcribeMediaUrl(audioUrl, "# Instagram Media Transcript");
11859
+ const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
11860
+ const diff = holdMc - settleMc;
11861
+ if (diff > 0) await creditMc(user.id, diff, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "real-cost reconcile");
11862
+ else if (diff < 0) await debitMc(user.id, -diff, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
11661
11863
  }
11662
11864
  }
11663
11865
  const result = {
@@ -11672,7 +11874,7 @@ instagramApp.post("/media-download", createApiKeyAuth(), async (c) => {
11672
11874
  return c.json(result);
11673
11875
  } catch (err) {
11674
11876
  const msg = err instanceof Error ? err.message : String(err);
11675
- if (transcriptDebited) await creditMc(user.id, MC_COSTS.instagram_transcribe, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "failed call");
11877
+ if (transcriptDebited) await creditMc(user.id, MEDIA_TRANSCRIBE_HOLD_MC, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "failed call");
11676
11878
  if (mediaDebited) await creditMc(user.id, MC_COSTS.instagram_media, LedgerOperation.INSTAGRAM_MEDIA_REFUND, "failed call");
11677
11879
  await logRequestEvent({ userId: user.id, source: "instagram_media_download", status: "failed", query: sourceUrl.href, error: msg });
11678
11880
  if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) return c.json({ error: msg }, 503);
@@ -11760,6 +11962,7 @@ redditApp.post("/thread", createApiKeyAuth(), async (c) => {
11760
11962
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
11761
11963
  let debited = false;
11762
11964
  let refunded = false;
11965
+ let commentDebitMc = 0;
11763
11966
  try {
11764
11967
  const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD, body.url);
11765
11968
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.reddit_thread), 402);
@@ -11799,10 +12002,23 @@ redditApp.post("/thread", createApiKeyAuth(), async (c) => {
11799
12002
  result.comments = result.comments.slice(0, body.maxComments);
11800
12003
  result.numComments = result.comments.length;
11801
12004
  }
12005
+ const commentCount = Array.isArray(result.comments) ? result.comments.length : 0;
12006
+ commentDebitMc = commentCount * MC_COSTS.reddit_comment;
12007
+ if (commentDebitMc > 0) {
12008
+ const commentDebit = await debitMc(user.id, commentDebitMc, LedgerOperation.REDDIT_COMMENT, body.url);
12009
+ if (!commentDebit.ok) {
12010
+ const affordable = Math.floor((commentDebit.balance_mc ?? 0) / MC_COSTS.reddit_comment);
12011
+ result.comments = result.comments.slice(0, affordable);
12012
+ result.numComments = affordable;
12013
+ commentDebitMc = affordable * MC_COSTS.reddit_comment;
12014
+ if (commentDebitMc > 0) await debitMc(user.id, commentDebitMc, LedgerOperation.REDDIT_COMMENT, body.url);
12015
+ }
12016
+ }
11802
12017
  await logRequestEvent({ userId: user.id, source: "reddit_thread", status: "done", query: body.url, resultCount: Number(result.numComments ?? 0), result });
11803
12018
  return c.json(result);
11804
12019
  } catch (err) {
11805
12020
  if (debited && !refunded) await creditMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD_REFUND, "failed call");
12021
+ if (commentDebitMc > 0) await creditMc(user.id, commentDebitMc, LedgerOperation.REDDIT_COMMENT_REFUND, "failed call");
11806
12022
  const msg = err instanceof Error ? err.message : String(err);
11807
12023
  await logRequestEvent({ userId: user.id, source: "reddit_thread", status: "failed", query: body.url, error: msg });
11808
12024
  return c.json({ error: msg }, 500);
@@ -12077,6 +12293,21 @@ videoApp.post("/status", createApiKeyAuth(), async (c) => {
12077
12293
  effectiveFrames: st.frameCount ?? null
12078
12294
  };
12079
12295
  }
12296
+ if ((st.status === "done" || st.status === "failed") && typeof st.costUsd === "number" && st.costUsd > 0) {
12297
+ const llmOp = `${LedgerOperation.VIDEO_ANALYSIS_LLM}:${runId}`;
12298
+ if (!await ledgerExistsForOperation(user.id, llmOp)) {
12299
+ await runWithCostContext({ ...currentCostContext(), op: "video_analysis", userId: user.id }, async () => {
12300
+ void recordVendorUsage({ vendor: "mcp_memory_video", model: null, units: st.costUsd, unitType: "usd", error: null });
12301
+ });
12302
+ const surchargeMc = Math.round(st.costUsd * VIDEO_ANALYSIS_LLM_MARGIN_MULTIPLE * MC_PER_USD);
12303
+ if (surchargeMc > 0) {
12304
+ const { ok } = await debitMc(user.id, surchargeMc, llmOp, `real-cost-surcharge:${runId}`);
12305
+ if (!ok) {
12306
+ console.warn(`[video-routes] VIDEO_ANALYSIS_LLM surcharge debit failed for user ${user.id} (run ${runId}): insufficient balance for ${surchargeMc}mc`);
12307
+ }
12308
+ }
12309
+ }
12310
+ }
12080
12311
  return c.json({
12081
12312
  ok: true,
12082
12313
  runId,
@@ -13247,7 +13478,7 @@ trustpilotApp.post("/reviews", createApiKeyAuth(), async (c) => {
13247
13478
  let pagesFetched = 0;
13248
13479
  try {
13249
13480
  await driver.launch({
13250
- headless: true,
13481
+ headless: false,
13251
13482
  kernelApiKey: browserServiceApiKey(),
13252
13483
  viewport: { width: 1280, height: 900 },
13253
13484
  locale: "en-US"
@@ -13440,7 +13671,7 @@ g2App.post("/reviews", createApiKeyAuth(), async (c) => {
13440
13671
  const driver = new BrowserDriver();
13441
13672
  try {
13442
13673
  await driver.launch({
13443
- headless: true,
13674
+ headless: false,
13444
13675
  kernelApiKey: browserServiceApiKey(),
13445
13676
  kernelProxyId: proxyId,
13446
13677
  viewport: { width: 1280, height: 900 },
@@ -20278,10 +20509,18 @@ function opForCostPath(p) {
20278
20509
  return "fb_ad";
20279
20510
  }
20280
20511
  if (p.startsWith("/instagram/")) return "instagram";
20512
+ if (p.startsWith("/google-ads/")) {
20513
+ if (p.endsWith("/transcribe")) return "google_ads_transcribe";
20514
+ if (p.endsWith("/search")) return "google_ads_search";
20515
+ return "google_ads_intel";
20516
+ }
20281
20517
  if (p === "/reddit/thread") return "reddit_thread";
20518
+ if (p.startsWith("/trustpilot/")) return "trustpilot_reviews";
20519
+ if (p.startsWith("/g2/")) return "g2_reviews";
20282
20520
  if (p.startsWith("/api/internal/site-architecture-auditor")) return "audit_site";
20283
20521
  if (p.startsWith("/api/internal/memory")) return "memory_ai";
20284
20522
  if (p.startsWith("/agent/")) return "browser_session";
20523
+ if (p.startsWith("/video/")) return "video_analysis";
20285
20524
  return null;
20286
20525
  }
20287
20526
  app.use("*", (c, next) => {
@@ -20993,16 +21232,20 @@ app.post("/harvest/sync", auth2, async (c) => {
20993
21232
  AbortSignal.timeout(syncTimeoutMs)
20994
21233
  ]);
20995
21234
  try {
20996
- const result = await harvest({
20997
- ...options,
20998
- kernelApiKey: browserServiceApiKey(),
20999
- headless: true,
21000
- format: "json",
21001
- outputDir: "/tmp/paa-output-api",
21002
- signal: syncSignal,
21003
- softDeadlineMs: Date.now() + Math.floor(syncTimeoutMs * 0.8),
21004
- onAttemptEvent: recordAttempt
21005
- });
21235
+ const harvestCtx = currentCostContext();
21236
+ const result = await runWithCostContext(
21237
+ { ...harvestCtx, op: options.serpOnly ? "serp" : "paa", userId: user.id },
21238
+ () => harvest({
21239
+ ...options,
21240
+ kernelApiKey: browserServiceApiKey(),
21241
+ headless: true,
21242
+ format: "json",
21243
+ outputDir: "/tmp/paa-output-api",
21244
+ signal: syncSignal,
21245
+ softDeadlineMs: Date.now() + Math.floor(syncTimeoutMs * 0.8),
21246
+ onAttemptEvent: recordAttempt
21247
+ })
21248
+ );
21006
21249
  await completeJob(jobId, result);
21007
21250
  const attempts = await listHarvestAttempts(jobId, user.id);
21008
21251
  if (!options.serpOnly) {
@@ -21681,7 +21924,7 @@ app.get("/cron/tick", async (c) => {
21681
21924
  if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
21682
21925
  return c.json({ error: "Unauthorized" }, 401);
21683
21926
  }
21684
- const { drainQueue } = await import("./worker-VASAJ7FZ.js");
21927
+ const { drainQueue } = await import("./worker-WCSKUAOT.js");
21685
21928
  const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
21686
21929
  const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
21687
21930
  const [results, sweepResult, reapResult, expiredResult, blobCleanup] = await Promise.all([
@@ -21699,7 +21942,7 @@ app.post("/api/internal/extract-refinalize/:id", async (c) => {
21699
21942
  return c.json({ error: "Unauthorized" }, 401);
21700
21943
  }
21701
21944
  const jobId = c.req.param("id");
21702
- const { getExtractJob: getExtractJob2, completeExtractJob: completeExtractJob2 } = await import("./site-extract-repository-LI2S3S6E.js");
21945
+ const { getExtractJob: getExtractJob2, completeExtractJob: completeExtractJob2 } = await import("./site-extract-repository-6X2C7RLF.js");
21703
21946
  const { assembleExtractArtifacts } = await import("./extract-bundle-COS56ZDO.js");
21704
21947
  const job = await getExtractJob2(jobId);
21705
21948
  if (!job) return c.json({ error: "job not found" }, 404);
@@ -21707,7 +21950,7 @@ app.post("/api/internal/extract-refinalize/:id", async (c) => {
21707
21950
  await completeExtractJob2(jobId, stored);
21708
21951
  let settlement = "already_settled_or_refunded";
21709
21952
  if (job.billedMc == null && job.userId != null) {
21710
- const { settleExtractJob: settleExtractJob2, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-LI2S3S6E.js");
21953
+ const { settleExtractJob: settleExtractJob2, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-6X2C7RLF.js");
21711
21954
  const heldMc = Number(job.options.heldMc ?? 0);
21712
21955
  const successful = await countSuccessfulPages2(jobId);
21713
21956
  const usedMc = Math.min(successful * MC_COSTS.page_scrape, heldMc);
@@ -21871,4 +22114,4 @@ app.get("/blog/:slug/", (c) => {
21871
22114
  export {
21872
22115
  app
21873
22116
  };
21874
- //# sourceMappingURL=server-R3KVQNOT.js.map
22117
+ //# sourceMappingURL=server-HNZG36IP.js.map