mcp-scraper 0.4.5 → 0.4.12

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 (52) hide show
  1. package/dist/bin/api-server.cjs +1866 -1376
  2. package/dist/bin/api-server.cjs.map +1 -1
  3. package/dist/bin/api-server.js +3 -3
  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 +89 -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 +2 -2
  16. package/dist/{chunk-NJK5BTUK.js → chunk-2YY46QYT.js} +28 -11
  17. package/dist/chunk-2YY46QYT.js.map +1 -0
  18. package/dist/{chunk-WI4A3KFQ.js → chunk-6EXP6DQG.js} +90 -2
  19. package/dist/chunk-6EXP6DQG.js.map +1 -0
  20. package/dist/{chunk-5LO6EHEH.js → chunk-EMY7ELRU.js} +35 -7
  21. package/dist/chunk-EMY7ELRU.js.map +1 -0
  22. package/dist/{chunk-FUVQR6GK.js → chunk-GL4BW4CP.js} +49 -1
  23. package/dist/chunk-GL4BW4CP.js.map +1 -0
  24. package/dist/{chunk-3UH3BEIZ.js → chunk-HE2LQPJ2.js} +2 -2
  25. package/dist/{chunk-4ZZWFI6L.js → chunk-ONIOF5XW.js} +3 -3
  26. package/dist/chunk-SHXJQQOH.js +7 -0
  27. package/dist/chunk-SHXJQQOH.js.map +1 -0
  28. package/dist/{db-H3S3M6KK.js → db-LIOTIWVN.js} +6 -2
  29. package/dist/{extract-bundle-UKE273TV.js → extract-bundle-COS56ZDO.js} +2 -2
  30. package/dist/index.cjs +59 -4
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.js +9 -3
  33. package/dist/index.js.map +1 -1
  34. package/dist/{server-XCDFHHLE.js → server-UNID3SJU.js} +570 -276
  35. package/dist/server-UNID3SJU.js.map +1 -0
  36. package/dist/{site-extract-repository-THBVEXMP.js → site-extract-repository-HTIY52MW.js} +4 -4
  37. package/dist/{worker-YHKUJR5P.js → worker-HTYZAYGB.js} +18 -14
  38. package/dist/worker-HTYZAYGB.js.map +1 -0
  39. package/package.json +2 -2
  40. package/dist/chunk-5LO6EHEH.js.map +0 -1
  41. package/dist/chunk-FUVQR6GK.js.map +0 -1
  42. package/dist/chunk-NJK5BTUK.js.map +0 -1
  43. package/dist/chunk-UGQC2FOX.js +0 -7
  44. package/dist/chunk-UGQC2FOX.js.map +0 -1
  45. package/dist/chunk-WI4A3KFQ.js.map +0 -1
  46. package/dist/server-XCDFHHLE.js.map +0 -1
  47. package/dist/worker-YHKUJR5P.js.map +0 -1
  48. /package/dist/{chunk-3UH3BEIZ.js.map → chunk-HE2LQPJ2.js.map} +0 -0
  49. /package/dist/{chunk-4ZZWFI6L.js.map → chunk-ONIOF5XW.js.map} +0 -0
  50. /package/dist/{db-H3S3M6KK.js.map → db-LIOTIWVN.js.map} +0 -0
  51. /package/dist/{extract-bundle-UKE273TV.js.map → extract-bundle-COS56ZDO.js.map} +0 -0
  52. /package/dist/{site-extract-repository-THBVEXMP.js.map → site-extract-repository-HTIY52MW.js.map} +0 -0
@@ -3,7 +3,7 @@ import {
3
3
  createHarvestAttemptRecorder,
4
4
  harvestProblemResponse,
5
5
  serializeHarvestProblem
6
- } from "./chunk-3UH3BEIZ.js";
6
+ } from "./chunk-HE2LQPJ2.js";
7
7
  import {
8
8
  BrowserDriver,
9
9
  MapsSelectors,
@@ -16,7 +16,7 @@ import {
16
16
  recordVendorUsage,
17
17
  resolveKernelProxyId,
18
18
  runWithCostContext
19
- } from "./chunk-NJK5BTUK.js";
19
+ } from "./chunk-2YY46QYT.js";
20
20
  import {
21
21
  HttpMcpToolExecutor,
22
22
  MemoryMcpToolExecutor,
@@ -31,7 +31,7 @@ import {
31
31
  registerSerpIntelligenceCaptureTools,
32
32
  sanitizeAttempts,
33
33
  sanitizeHarvestResult
34
- } from "./chunk-WI4A3KFQ.js";
34
+ } from "./chunk-6EXP6DQG.js";
35
35
  import {
36
36
  auditImages,
37
37
  getBlobStore
@@ -65,7 +65,7 @@ import {
65
65
  RawMapsOverviewSchema,
66
66
  RawMapsReviewStatsSchema
67
67
  } from "./chunk-XGIPATLV.js";
68
- import "./chunk-UGQC2FOX.js";
68
+ import "./chunk-SHXJQQOH.js";
69
69
  import {
70
70
  completeExtractJob,
71
71
  countSuccessfulPages,
@@ -76,7 +76,7 @@ import {
76
76
  saveExtractPages,
77
77
  setExtractJobTotal,
78
78
  settleExtractJob
79
- } from "./chunk-4ZZWFI6L.js";
79
+ } from "./chunk-ONIOF5XW.js";
80
80
  import {
81
81
  BROWSER_OPEN_MIN_BALANCE_MC,
82
82
  CONCURRENCY_PRICE_ID,
@@ -90,12 +90,14 @@ import {
90
90
  MEMORY_PLANS,
91
91
  MEMORY_PLAN_QUOTA,
92
92
  SCHEDULING_PLANS,
93
+ SITE_AUDIT_LLM_MARGIN_MULTIPLE,
93
94
  SUBSCRIPTION_TIERS,
94
95
  SUBSCRIPTION_TIER_BY_KEY,
96
+ VIDEO_ANALYSIS_LLM_MARGIN_MULTIPLE,
95
97
  browserActiveCostMc,
96
98
  concurrencySlotBillingInfo,
97
99
  insufficientBalanceResponse
98
- } from "./chunk-5LO6EHEH.js";
100
+ } from "./chunk-EMY7ELRU.js";
99
101
  import {
100
102
  CaptchaError,
101
103
  RECAPTCHA_INSTRUCTIONS,
@@ -131,6 +133,7 @@ import {
131
133
  getLedger,
132
134
  getMemoryPlan,
133
135
  getMemoryProvisioned,
136
+ getPageSnapshot,
134
137
  getRefresh,
135
138
  getUserByApiKey,
136
139
  getUserByEmail,
@@ -175,8 +178,9 @@ import {
175
178
  setStripeCustomerId,
176
179
  setSubscriptionTier,
177
180
  setUserMemoryCreds,
181
+ upsertPageSnapshot,
178
182
  verifyPassword
179
- } from "./chunk-FUVQR6GK.js";
183
+ } from "./chunk-GL4BW4CP.js";
180
184
 
181
185
  // src/blog/registry.ts
182
186
  var posts = [
@@ -5337,6 +5341,7 @@ function extractJson(raw) {
5337
5341
  var DeepInfraLlmClient = class {
5338
5342
  apiKey;
5339
5343
  model;
5344
+ totalCostUsd = 0;
5340
5345
  constructor(apiKey, model = "Qwen/Qwen3.6-35B-A3B") {
5341
5346
  this.apiKey = apiKey;
5342
5347
  this.model = model;
@@ -5369,6 +5374,7 @@ ${raw}`
5369
5374
  var OpenRouterLlmClient = class {
5370
5375
  apiKey;
5371
5376
  model;
5377
+ totalCostUsd = 0;
5372
5378
  constructor(apiKey, model = "qwen/qwen3-235b-a22b") {
5373
5379
  this.apiKey = apiKey;
5374
5380
  this.model = model;
@@ -5381,6 +5387,9 @@ var OpenRouterLlmClient = class {
5381
5387
  });
5382
5388
  if (!resp.ok) throw new Error(`OpenRouter error ${resp.status}: ${await resp.text()}`);
5383
5389
  const data = await resp.json();
5390
+ const cost = data.usage?.cost ?? 0;
5391
+ this.totalCostUsd += cost;
5392
+ void recordVendorUsage({ vendor: "openrouter", model: this.model, units: cost, unitType: "usd" });
5384
5393
  return data.choices[0].message.content;
5385
5394
  }
5386
5395
  async completeJson(prompt) {
@@ -5406,6 +5415,9 @@ var FallbackLlmClient = class {
5406
5415
  secondary;
5407
5416
  lastServedBy = null;
5408
5417
  lastPrimaryError = null;
5418
+ get totalCostUsd() {
5419
+ return this.primary.totalCostUsd + this.secondary.totalCostUsd;
5420
+ }
5409
5421
  async complete(prompt) {
5410
5422
  try {
5411
5423
  const result = await this.primary.complete(prompt);
@@ -6892,8 +6904,23 @@ var SiteAuditService = class {
6892
6904
  async getJobsForUser(userId) {
6893
6905
  return this.deps.repo.listSiteAuditJobs(userId);
6894
6906
  }
6907
+ async runLlmBilled(userId, source, fn) {
6908
+ const before = this.deps.llm.totalCostUsd;
6909
+ const result = await runWithCostContext({ ...currentCostContext(), op: "audit_site", userId }, fn);
6910
+ const deltaCostUsd = this.deps.llm.totalCostUsd - before;
6911
+ if (deltaCostUsd <= 0) return result;
6912
+ const mc = Math.round(deltaCostUsd * SITE_AUDIT_LLM_MARGIN_MULTIPLE * MC_PER_USD);
6913
+ if (mc <= 0) return result;
6914
+ const { ok } = await debitMc(userId, mc, LedgerOperation.SITE_AUDIT_LLM, source);
6915
+ if (!ok) {
6916
+ console.warn(`[site-audit-service] SITE_AUDIT_LLM surcharge debit failed for user ${userId} (${source}): insufficient balance for ${mc}mc`);
6917
+ }
6918
+ return result;
6919
+ }
6895
6920
  async runIngestPhase(jobId, payload) {
6896
6921
  const { request } = payload;
6922
+ const job = await this.deps.repo.getSiteAuditJob(jobId);
6923
+ if (!job) throw new Error(`[site-audit-service] runIngestPhase: job ${jobId} not found`);
6897
6924
  const input = {
6898
6925
  clientDomain: request.clientDomain,
6899
6926
  sfInternalPath: request.sfInternalPath,
@@ -6908,14 +6935,18 @@ var SiteAuditService = class {
6908
6935
  businessContext: request.businessContext,
6909
6936
  priorAuditMemoryEnabled: request.priorAuditMemoryEnabled
6910
6937
  };
6911
- const result = await llmParseWithRetry(
6912
- ValidatedFileManifestSchema,
6913
- (attempt, error) => {
6914
- void attempt;
6915
- void error;
6916
- return buildIngestValidatePrompt(input);
6917
- },
6918
- this.deps.llm
6938
+ const result = await this.runLlmBilled(
6939
+ job.user_id,
6940
+ `${jobId}:phase1-ingest`,
6941
+ () => llmParseWithRetry(
6942
+ ValidatedFileManifestSchema,
6943
+ (attempt, error) => {
6944
+ void attempt;
6945
+ void error;
6946
+ return buildIngestValidatePrompt(input);
6947
+ },
6948
+ this.deps.llm
6949
+ )
6919
6950
  );
6920
6951
  await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase1-ingest", result);
6921
6952
  }
@@ -6930,23 +6961,27 @@ var SiteAuditService = class {
6930
6961
  const competitorDomainsCsvPath = request.competitorDomainsCsvPath;
6931
6962
  void competitorDomainsCsvPath;
6932
6963
  const competitors = await this._readCompetitorDomains(request);
6933
- const results = await Promise.all(
6934
- competitors.map(
6935
- (domain, idx) => limit(async () => {
6936
- const competitorIndex = idx + 1;
6937
- const rawPrompt = buildEnrichCompetitorPrompt({ domain, sessionPath });
6938
- const prompt = rawPrompt.replace("<competitor-index>", String(competitorIndex));
6939
- const result = await llmParseWithRetry(
6940
- EnrichCompetitorOutputSchema,
6941
- (attempt, error) => {
6942
- void attempt;
6943
- void error;
6944
- return prompt;
6945
- },
6946
- this.deps.llm
6947
- );
6948
- return result;
6949
- })
6964
+ const results = await this.runLlmBilled(
6965
+ job.user_id,
6966
+ `${jobId}:phase1-enrich`,
6967
+ () => Promise.all(
6968
+ competitors.map(
6969
+ (domain, idx) => limit(async () => {
6970
+ const competitorIndex = idx + 1;
6971
+ const rawPrompt = buildEnrichCompetitorPrompt({ domain, sessionPath });
6972
+ const prompt = rawPrompt.replace("<competitor-index>", String(competitorIndex));
6973
+ const result = await llmParseWithRetry(
6974
+ EnrichCompetitorOutputSchema,
6975
+ (attempt, error) => {
6976
+ void attempt;
6977
+ void error;
6978
+ return prompt;
6979
+ },
6980
+ this.deps.llm
6981
+ );
6982
+ return result;
6983
+ })
6984
+ )
6950
6985
  )
6951
6986
  );
6952
6987
  await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase1-ingest", { competitors: results });
@@ -6960,19 +6995,23 @@ var SiteAuditService = class {
6960
6995
  const clientDomain = request.clientDomain;
6961
6996
  const urlInventory = [];
6962
6997
  const directedEdgeCount = 0;
6963
- const orphanAnnotation = await llmParseWithRetry(
6964
- OrphanAnnotationOutputSchema,
6965
- (attempt, error) => {
6966
- void attempt;
6967
- void error;
6968
- return buildGraphPerSitePrompt({
6969
- siteId: "client",
6970
- domain: clientDomain,
6971
- urlInventory,
6972
- directedEdgeCount
6973
- });
6974
- },
6975
- this.deps.llm
6998
+ const orphanAnnotation = await this.runLlmBilled(
6999
+ job.user_id,
7000
+ `${jobId}:phase2-build-graph`,
7001
+ () => llmParseWithRetry(
7002
+ OrphanAnnotationOutputSchema,
7003
+ (attempt, error) => {
7004
+ void attempt;
7005
+ void error;
7006
+ return buildGraphPerSitePrompt({
7007
+ siteId: "client",
7008
+ domain: clientDomain,
7009
+ urlInventory,
7010
+ directedEdgeCount
7011
+ });
7012
+ },
7013
+ this.deps.llm
7014
+ )
6976
7015
  );
6977
7016
  const graphMetrics = await this.deps.python.runGraphMetrics({
6978
7017
  directedLinkGraphPath: `${sessionPath}/phase1/directed_link_graph.json`,
@@ -7017,50 +7056,52 @@ var SiteAuditService = class {
7017
7056
  }
7018
7057
  if (currentBatch.length > 0) batches.push(currentBatch);
7019
7058
  const usZipsCsvPath = process.env["US_ZIPS_CSV_PATH"] ?? "/Users/vilovieta/Downloads/sales-magician-api-leads-magician-01c6cff78e31/tools/analytics/data/uszips.csv";
7020
- for (const batch of batches) {
7021
- let classified = [];
7022
- let unclassifiedUrls = batch.map((u) => u.url);
7023
- try {
7024
- const pythonResult = await this.deps.python.runLocationClassifier({
7025
- urls: batch.map((u) => u.url),
7026
- usZipsCsvPath
7027
- });
7028
- classified = pythonResult.classified;
7029
- unclassifiedUrls = pythonResult.unclassified;
7030
- } catch (err) {
7031
- console.warn(
7032
- "[site-audit-service] runClassifyPhase: python.runLocationClassifier failed (falling back to LLM):",
7033
- err instanceof Error ? err.message : String(err)
7034
- );
7035
- unclassifiedUrls = batch.map((u) => u.url);
7036
- }
7037
- for (const c of classified) {
7038
- allClassifications.push({
7039
- url: c.url,
7040
- page_type: c.pageType,
7041
- confidence: c.confidence > 0.6 ? "high" : c.confidence > 0.4 ? "medium" : "low",
7042
- _classification_label: "[COMPUTED:python/location-classifier]",
7043
- topic_cluster: null,
7044
- location_signals: c.locationSignals
7045
- });
7046
- }
7047
- if (unclassifiedUrls.length > 0) {
7048
- const unclassifiedBatch = batch.filter((u) => unclassifiedUrls.includes(u.url));
7049
- const llmResults = await llmParseWithRetry(
7050
- z9.array(PageTypeClassificationRowSchema),
7051
- (attempt, error) => {
7052
- void attempt;
7053
- void error;
7054
- return buildMetricsClassifyPrompt({
7055
- urls: unclassifiedBatch,
7056
- siteDomain: clientDomain
7057
- });
7058
- },
7059
- this.deps.llm
7060
- );
7061
- allClassifications.push(...llmResults);
7059
+ await this.runLlmBilled(job.user_id, `${jobId}:phase3-classify`, async () => {
7060
+ for (const batch of batches) {
7061
+ let classified = [];
7062
+ let unclassifiedUrls = batch.map((u) => u.url);
7063
+ try {
7064
+ const pythonResult = await this.deps.python.runLocationClassifier({
7065
+ urls: batch.map((u) => u.url),
7066
+ usZipsCsvPath
7067
+ });
7068
+ classified = pythonResult.classified;
7069
+ unclassifiedUrls = pythonResult.unclassified;
7070
+ } catch (err) {
7071
+ console.warn(
7072
+ "[site-audit-service] runClassifyPhase: python.runLocationClassifier failed (falling back to LLM):",
7073
+ err instanceof Error ? err.message : String(err)
7074
+ );
7075
+ unclassifiedUrls = batch.map((u) => u.url);
7076
+ }
7077
+ for (const c of classified) {
7078
+ allClassifications.push({
7079
+ url: c.url,
7080
+ page_type: c.pageType,
7081
+ confidence: c.confidence > 0.6 ? "high" : c.confidence > 0.4 ? "medium" : "low",
7082
+ _classification_label: "[COMPUTED:python/location-classifier]",
7083
+ topic_cluster: null,
7084
+ location_signals: c.locationSignals
7085
+ });
7086
+ }
7087
+ if (unclassifiedUrls.length > 0) {
7088
+ const unclassifiedBatch = batch.filter((u) => unclassifiedUrls.includes(u.url));
7089
+ const llmResults = await llmParseWithRetry(
7090
+ z9.array(PageTypeClassificationRowSchema),
7091
+ (attempt, error) => {
7092
+ void attempt;
7093
+ void error;
7094
+ return buildMetricsClassifyPrompt({
7095
+ urls: unclassifiedBatch,
7096
+ siteDomain: clientDomain
7097
+ });
7098
+ },
7099
+ this.deps.llm
7100
+ );
7101
+ allClassifications.push(...llmResults);
7102
+ }
7062
7103
  }
7063
- }
7104
+ });
7064
7105
  void sessionPath;
7065
7106
  await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase3-classify", {
7066
7107
  classifications: allClassifications,
@@ -7073,20 +7114,24 @@ var SiteAuditService = class {
7073
7114
  if (!job) throw new Error(`[site-audit-service] runComparePhase: job ${jobId} not found`);
7074
7115
  const request = JSON.parse(job.request);
7075
7116
  const clientDomain = request.clientDomain;
7076
- const result = await llmParseWithRetry(
7077
- CompareRecommendOutputSchema,
7078
- (attempt, error) => {
7079
- void attempt;
7080
- void error;
7081
- return buildCompareRecommendPrompt({
7082
- clientDomain,
7083
- clientMoneyPages: [],
7084
- competitorSites: [],
7085
- clientUrlMetrics: [],
7086
- clientPageTypes: []
7087
- });
7088
- },
7089
- this.deps.llm
7117
+ const result = await this.runLlmBilled(
7118
+ job.user_id,
7119
+ `${jobId}:phase4-compare`,
7120
+ () => llmParseWithRetry(
7121
+ CompareRecommendOutputSchema,
7122
+ (attempt, error) => {
7123
+ void attempt;
7124
+ void error;
7125
+ return buildCompareRecommendPrompt({
7126
+ clientDomain,
7127
+ clientMoneyPages: [],
7128
+ competitorSites: [],
7129
+ clientUrlMetrics: [],
7130
+ clientPageTypes: []
7131
+ });
7132
+ },
7133
+ this.deps.llm
7134
+ )
7090
7135
  );
7091
7136
  await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase4-compare", result);
7092
7137
  return result;
@@ -7116,20 +7161,24 @@ var SiteAuditService = class {
7116
7161
  total: ilesTotal,
7117
7162
  components: ilesComponents
7118
7163
  };
7119
- const reportMarkdown = await llmParseWithRetry(
7120
- z9.string(),
7121
- (attempt, error) => {
7122
- void attempt;
7123
- void error;
7124
- return buildScoreSynthesizePrompt({
7125
- preComputedArchitectureScore,
7126
- preComputedIlesScore,
7127
- rankedBacklog: [],
7128
- clientDomain,
7129
- sessionPath
7130
- });
7131
- },
7132
- this.deps.llm
7164
+ const reportMarkdown = await this.runLlmBilled(
7165
+ job.user_id,
7166
+ `${jobId}:phase5-synthesize`,
7167
+ () => llmParseWithRetry(
7168
+ z9.string(),
7169
+ (attempt, error) => {
7170
+ void attempt;
7171
+ void error;
7172
+ return buildScoreSynthesizePrompt({
7173
+ preComputedArchitectureScore,
7174
+ preComputedIlesScore,
7175
+ rankedBacklog: [],
7176
+ clientDomain,
7177
+ sessionPath
7178
+ });
7179
+ },
7180
+ this.deps.llm
7181
+ )
7133
7182
  );
7134
7183
  const auditResult = SiteAuditResultSchema.parse({
7135
7184
  jobId,
@@ -7529,7 +7578,7 @@ var siteExtractFn = inngest.createFunction(
7529
7578
  return auditImages(pages, { concurrency: 12, timeoutMs: 12e3 });
7530
7579
  }) : null;
7531
7580
  const artifacts = await step.run("finalize", async () => {
7532
- const { assembleExtractArtifacts } = await import("./extract-bundle-UKE273TV.js");
7581
+ const { assembleExtractArtifacts } = await import("./extract-bundle-COS56ZDO.js");
7533
7582
  const stored = await assembleExtractArtifacts(job, { branding, imageAudit });
7534
7583
  await completeExtractJob(jobId, stored);
7535
7584
  return stored;
@@ -8138,6 +8187,11 @@ internalMemoryApp.post("/ai-debit", async (c) => {
8138
8187
  }
8139
8188
  const user = await getUserByEmail(body.identity.trim().toLowerCase());
8140
8189
  if (!user) return c.json({ ok: false, reason: "no_user" }, 200);
8190
+ if (body.costUsd > 0) {
8191
+ await runWithCostContext({ ...currentCostContext(), op: "memory_ai", userId: user.id }, async () => {
8192
+ void recordVendorUsage({ vendor: "mcp_memory_ai", model: null, units: body.costUsd, unitType: "usd", error: null });
8193
+ });
8194
+ }
8141
8195
  const mc = Math.round(body.costUsd * MEMORY_AI_MARGIN_MULTIPLE * MC_PER_USD);
8142
8196
  if (mc <= 0) return c.json({ ok: true, mc: 0, balance_mc: null }, 200);
8143
8197
  const { ok, balance_mc } = await debitMc(user.id, mc, LedgerOperation.MEMORY_AI, `${body.source ?? "ai"}:${body.op ?? ""}`);
@@ -8658,6 +8712,11 @@ async function fetchViaYoutubeTranscript(videoId) {
8658
8712
  }));
8659
8713
  const text = chunks.map((c) => c.text).join(" ");
8660
8714
  const last = chunks[chunks.length - 1];
8715
+ const now = Date.now();
8716
+ await runWithCostContext(
8717
+ { ...currentCostContext(), op: "yt_transcription", subOp: "youtube-transcript" },
8718
+ () => recordKernelSession({ kernelSessionId: null, source: "yt_caption_tier", headlessSent: null, openedAtMs: now, closedAtMs: now })
8719
+ );
8661
8720
  return { videoId, text, chunks, durationMs: last ? last.timestamp[1] * 1e3 : 0, method: "youtube-transcript" };
8662
8721
  } catch {
8663
8722
  return null;
@@ -8709,163 +8768,221 @@ function parseTimedtextXml(xml) {
8709
8768
  }
8710
8769
  return results;
8711
8770
  }
8712
- async function fetchViaKernelInnertube(videoId) {
8713
- const kernelApiKey = browserServiceApiKey();
8714
- if (!kernelApiKey) return null;
8715
- const kernelProxyId = browserServiceProxyId();
8716
- const driver = new BrowserDriver();
8717
- const start = Date.now();
8718
- try {
8719
- await driver.launch({ kernelApiKey, kernelProxyId, headless: true, viewport: { width: 1280, height: 800 }, locale: "en-US" });
8720
- const page = driver.getPage();
8721
- await driver.navigateTo(`https://www.youtube.com/watch?v=${videoId}`);
8722
- await page.waitForFunction(
8723
- () => !!window.ytcfg,
8724
- { timeout: 2e4 }
8725
- );
8726
- const xml = await page.evaluate(async (vid) => {
8727
- const ANDROID_VERSION = "20.10.38";
8728
- const resp = await fetch("/youtubei/v1/player?prettyPrint=false", {
8729
- method: "POST",
8730
- headers: {
8731
- "Content-Type": "application/json",
8732
- "User-Agent": `com.google.android.youtube/${ANDROID_VERSION} (Linux; U; Android 14)`
8733
- },
8734
- body: JSON.stringify({
8735
- context: { client: { clientName: "ANDROID", clientVersion: ANDROID_VERSION } },
8736
- videoId: vid
8737
- })
8771
+ var INNERTUBE_CLIENTS = [
8772
+ { name: "ANDROID", version: "20.10.38", userAgent: "com.google.android.youtube/20.10.38 (Linux; U; Android 14)" },
8773
+ { 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
+ { name: "ANDROID_VR", version: "1.62.27", userAgent: "com.google.android.apps.youtube.vr.oculus/1.62.27 (Linux; U; Android 12L) gzip" }
8775
+ ];
8776
+ function isBotBlockStatus(status) {
8777
+ return status === "LOGIN_REQUIRED" || status === "AGE_VERIFICATION_REQUIRED";
8778
+ }
8779
+ async function probePlayer(page, videoId) {
8780
+ return page.evaluate(
8781
+ async ({ vid, clients }) => {
8782
+ let lastStatus = null;
8783
+ for (const client2 of clients) {
8784
+ const resp = await fetch("/youtubei/v1/player?prettyPrint=false", {
8785
+ method: "POST",
8786
+ headers: { "Content-Type": "application/json", "User-Agent": client2.userAgent },
8787
+ body: JSON.stringify({
8788
+ context: { client: { clientName: client2.name, clientVersion: client2.version } },
8789
+ videoId: vid
8790
+ })
8791
+ }).catch(() => null);
8792
+ if (!resp?.ok) {
8793
+ lastStatus = resp ? `player_http_${resp.status}` : "player_fetch_failed";
8794
+ continue;
8795
+ }
8796
+ const data = await resp.json();
8797
+ 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;
8801
+ const tracks = data?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
8802
+ if (tracks.length > 0) {
8803
+ const track = tracks.find((t) => t.languageCode === "en") ?? tracks[0];
8804
+ const sep = track.baseUrl.includes("?") ? "&" : "?";
8805
+ const xmlResp = await fetch(`${track.baseUrl}${sep}fmt=json3`).catch(() => null);
8806
+ if (xmlResp?.ok) captions = await xmlResp.text();
8807
+ }
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
+ }
8821
+ return { captions: null, audio: null, status: lastStatus };
8822
+ },
8823
+ { vid: videoId, clients: INNERTUBE_CLIENTS }
8824
+ ).catch(() => null);
8825
+ }
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);
8738
8844
  });
8739
- if (!resp.ok) return null;
8740
- const data = await resp.json();
8741
- const tracks = data?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
8742
- if (tracks.length === 0) return null;
8743
- const track = tracks.find((t) => t.languageCode === "en") ?? tracks[0];
8744
- const sep = track.baseUrl.includes("?") ? "&" : "?";
8745
- const xmlResp = await fetch(`${track.baseUrl}${sep}fmt=json3`);
8746
- return xmlResp.ok ? xmlResp.text() : null;
8747
- }, videoId);
8748
- if (!xml) return null;
8749
- let entries = parseTimedtextJson3(xml);
8750
- if (entries.length === 0) entries = parseTimedtextXml(xml);
8751
- if (entries.length === 0) return null;
8752
- const chunks = entries.map((e) => ({
8753
- timestamp: [e.start / 1e3, (e.start + e.dur) / 1e3],
8754
- text: e.text
8755
- }));
8756
- const text = chunks.map((c) => c.text).join(" ");
8757
- const last = chunks[chunks.length - 1];
8758
- return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-innertube" };
8759
- } catch {
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`);
8760
8852
  return null;
8761
- } finally {
8762
- await driver.close();
8763
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() };
8764
8860
  }
8765
- var WHISPER_RECORD_SECONDS = 90;
8766
- async function captureAudioViaMediaRecorder(page) {
8767
- const result = await page.evaluate((recordSecs) => {
8768
- return new Promise((resolve) => {
8769
- const video = document.querySelector("video");
8770
- if (!video) {
8771
- resolve({ ok: false, error: "no video element" });
8772
- return;
8773
- }
8774
- video.muted = false;
8775
- video.volume = 1;
8776
- try {
8777
- const ctx = new AudioContext();
8778
- const src = ctx.createMediaElementSource(video);
8779
- const dest = ctx.createMediaStreamDestination();
8780
- src.connect(dest);
8781
- src.connect(ctx.destination);
8782
- const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "audio/ogg";
8783
- const recorder = new MediaRecorder(dest.stream, { mimeType, audioBitsPerSecond: 48e3 });
8784
- const chunks = [];
8785
- recorder.ondataavailable = (e) => {
8786
- if (e.data.size > 0) chunks.push(e.data);
8787
- };
8788
- recorder.onstop = async () => {
8789
- const blob = new Blob(chunks, { type: mimeType });
8790
- const ab = await blob.arrayBuffer();
8791
- const arr = new Uint8Array(ab);
8792
- let binary = "";
8793
- const chunkSize = 8192;
8794
- for (let i = 0; i < arr.length; i += chunkSize) {
8795
- binary += String.fromCharCode(...arr.slice(i, Math.min(i + chunkSize, arr.length)));
8796
- }
8797
- resolve({ ok: true, base64: btoa(binary), size: ab.byteLength, mimeType });
8798
- };
8799
- recorder.start(1e3);
8800
- video.playbackRate = 2;
8801
- video.play().catch(() => void 0);
8802
- setTimeout(() => {
8803
- recorder.stop();
8804
- ctx.close();
8805
- }, recordSecs * 1e3);
8806
- } catch (e) {
8807
- resolve({ ok: false, error: String(e) });
8808
- }
8809
- });
8810
- }, WHISPER_RECORD_SECONDS);
8811
- if (!result.ok || !result.base64 || !result.mimeType) return null;
8812
- return { bytes: Buffer.from(result.base64, "base64"), mimeType: result.mimeType };
8813
- }
8814
- async function attemptKernelWhisper(videoId, kernelApiKey, kernelProxyId, falKey, start) {
8815
- const driver = new BrowserDriver();
8861
+ async function transcribeWithWizper(videoId, audio, startMs) {
8862
+ const falKey = process.env.FAL_KEY;
8863
+ if (!falKey) return null;
8816
8864
  try {
8817
- await driver.launch({ kernelApiKey, kernelProxyId, headless: true, viewport: { width: 1280, height: 800 }, locale: "en-US" });
8818
- const page = driver.getPage();
8819
- await driver.navigateTo(`https://www.youtube.com/watch?v=${videoId}`);
8820
- await page.waitForFunction(
8821
- () => !!window.ytcfg,
8822
- { timeout: 2e4 }
8823
- );
8824
- const playStatus = await page.evaluate(() => {
8825
- const w = window;
8826
- return w.ytInitialPlayerResponse?.playabilityStatus?.status;
8827
- });
8828
- if (playStatus !== "OK") {
8829
- await driver.close();
8830
- return null;
8831
- }
8832
- const audioInfo = await captureAudioViaMediaRecorder(page);
8833
- await driver.close();
8834
- if (!audioInfo || audioInfo.bytes.length < 1e4) return null;
8835
8865
  fal.config({ credentials: falKey });
8836
- const ext = audioInfo.mimeType.includes("ogg") ? "ogg" : "webm";
8837
- const audioFile = new File([new Uint8Array(audioInfo.bytes)], `audio.${ext}`, { type: audioInfo.mimeType });
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 });
8838
8868
  const uploadedUrl = await fal.storage.upload(audioFile);
8869
+ const subscribeStartMs = Date.now();
8839
8870
  const result = await fal.subscribe("fal-ai/wizper", {
8840
- input: { audio_url: uploadedUrl, task: "transcribe", language: "en" },
8871
+ input: { audio_url: uploadedUrl, task: "transcribe", language: "en", chunk_level: "segment" },
8841
8872
  logs: false,
8842
8873
  pollInterval: 3e3
8843
8874
  });
8875
+ const subscribeDurationMs = Date.now() - subscribeStartMs;
8844
8876
  const data = result.data;
8845
8877
  const chunks = (data.chunks ?? []).map((c) => ({
8846
8878
  timestamp: c.timestamp,
8847
8879
  text: c.text.trim()
8848
8880
  }));
8849
- const audioSec = chunks.length ? chunks[chunks.length - 1]?.timestamp?.[1] ?? 0 : 0;
8850
- void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: audioSec / 60, unitType: "audio_min" });
8881
+ void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: subscribeDurationMs / 1e3, unitType: "compute_sec" });
8851
8882
  const text = data.text ?? chunks.map((c) => c.text).join(" ");
8852
8883
  if (!text) return null;
8853
- return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-whisper" };
8854
- } catch {
8855
- await driver.close();
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)}`);
8856
8887
  return null;
8857
8888
  }
8858
8889
  }
8859
- async function fetchViaKernelWhisper(videoId) {
8890
+ var YT_RESIDENTIAL_LOCATION = process.env.YOUTUBE_PROXY_LOCATION ?? "Austin, Texas";
8891
+ async function resolveResidentialProxy(kernelApiKey) {
8892
+ try {
8893
+ const resolved = await resolveKernelProxyId({
8894
+ kernelApiKey,
8895
+ proxyMode: "location",
8896
+ location: YT_RESIDENTIAL_LOCATION,
8897
+ gl: "us",
8898
+ fresh: true
8899
+ });
8900
+ return { kernelProxyId: resolved.kernelProxyId, disposableProxyId: resolved.disposableProxyId };
8901
+ } catch (err) {
8902
+ console.warn(`[CaptionFetcher] residential proxy resolution failed: ${err instanceof Error ? err.message : String(err)}`);
8903
+ return {};
8904
+ }
8905
+ }
8906
+ async function probeAndExtract(videoId, opts) {
8860
8907
  const kernelApiKey = browserServiceApiKey();
8861
- const kernelProxyId = browserServiceProxyId();
8862
- const falKey = process.env.FAL_KEY;
8863
- if (!kernelApiKey || !falKey) return null;
8908
+ if (!kernelApiKey) return null;
8909
+ const kernelProxyId = opts?.kernelProxyId ?? browserServiceProxyId();
8910
+ const driver = new BrowserDriver();
8911
+ return runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: "browser-innertube" }, async () => {
8912
+ let entries = [];
8913
+ let audio = null;
8914
+ let retryWithFreshIp = false;
8915
+ try {
8916
+ await driver.launch({ kernelApiKey, kernelProxyId, headless: true, headlessMode: "headless", viewport: { width: 1280, height: 800 }, locale: "en-US" });
8917
+ const page = driver.getPage();
8918
+ await driver.navigateTo(`https://www.youtube.com/watch?v=${videoId}`);
8919
+ await page.waitForFunction(
8920
+ () => !!window.ytcfg,
8921
+ { timeout: 2e4 }
8922
+ );
8923
+ const probe = await probePlayer(page, videoId);
8924
+ if (!probe) return null;
8925
+ if (probe.captions && !opts?.skipCaptions) {
8926
+ entries = parseTimedtextJson3(probe.captions);
8927
+ if (entries.length === 0) entries = parseTimedtextXml(probe.captions);
8928
+ }
8929
+ 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 };
8937
+ }
8938
+ } catch (err) {
8939
+ console.warn(`[CaptionFetcher] kernel session failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
8940
+ return null;
8941
+ } finally {
8942
+ const servedSubOp = audio ? "browser-whisper" : "browser-innertube";
8943
+ await runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: servedSubOp }, () => driver.close());
8944
+ }
8945
+ return { entries, audio, retryWithFreshIp };
8946
+ });
8947
+ }
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
+ async function fetchViaKernelSession(videoId) {
8864
8970
  const start = Date.now();
8865
- for (let attempt = 0; attempt < 4; attempt++) {
8866
- if (attempt > 0) await new Promise((r) => setTimeout(r, 2e3 * attempt));
8867
- const result = await attemptKernelWhisper(videoId, kernelApiKey, kernelProxyId, falKey, start);
8868
- if (result) return result;
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
+ );
8869
8986
  }
8870
8987
  return null;
8871
8988
  }
@@ -8887,11 +9004,11 @@ function finalizeCaptions(result) {
8887
9004
  async function fetchCaptions(videoId) {
8888
9005
  const primary = await fetchViaYoutubeTranscript(videoId);
8889
9006
  if (primary) return finalizeCaptions(primary);
8890
- const kernel = await fetchViaKernelInnertube(videoId);
8891
- if (kernel) return finalizeCaptions(kernel);
8892
- const whisper = await fetchViaKernelWhisper(videoId);
8893
- if (whisper) return finalizeCaptions(whisper);
8894
- throw new Error(`No captions available for ${videoId} \u2014 all three tiers failed`);
9007
+ const result = await fetchViaKernelSession(videoId);
9008
+ if (result) return finalizeCaptions(result);
9009
+ 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.`
9011
+ );
8895
9012
  }
8896
9013
 
8897
9014
  // src/api/api-auth.ts
@@ -8935,6 +9052,11 @@ var ExtractUrlBodySchema = z11.object({
8935
9052
  depositToVault: z11.boolean().optional(),
8936
9053
  vaultName: z11.string().trim().min(1).max(120).optional()
8937
9054
  });
9055
+ var DiffPageBodySchema = z11.object({
9056
+ url: z11.string().min(1, "url is required"),
9057
+ allowLocal: z11.boolean().optional(),
9058
+ resetBaseline: z11.boolean().optional()
9059
+ });
8938
9060
  var MapUrlsBodySchema = z11.object({
8939
9061
  url: z11.string().min(1, "url is required"),
8940
9062
  maxUrls: z11.number().int().min(1).max(2e3).optional(),
@@ -9214,7 +9336,7 @@ youtubeApp.post("/transcribe", createApiKeyAuth(), async (c) => {
9214
9336
  const { ok: trOk, balance_mc: trBal } = await debitMc(user.id, holdMc, LedgerOperation.TRANSCRIPTION_HOLD, id);
9215
9337
  if (!trOk) return c.json(insufficientBalanceResponse(trBal, holdMc), 402);
9216
9338
  debited = true;
9217
- const result = await fetchCaptions(id);
9339
+ const result = await runWithCostContext({ ...currentCostContext(), op: "yt_transcription", userId: user.id }, () => fetchCaptions(id));
9218
9340
  const lastChunk = result.chunks?.[result.chunks.length - 1];
9219
9341
  const chunkSecs = lastChunk && Number.isFinite(lastChunk.timestamp[1]) ? lastChunk.timestamp[1] : 0;
9220
9342
  const durationSecs = Number.isFinite(result.durationMs) ? result.durationMs / 1e3 : 0;
@@ -10277,8 +10399,7 @@ async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript"
10277
10399
  const text = data.text ?? "";
10278
10400
  const chunks = data.chunks ?? [];
10279
10401
  const durationMs = Date.now() - startMs;
10280
- const audioSec = chunks.length ? chunks[chunks.length - 1]?.timestamp?.[1] ?? 0 : 0;
10281
- void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: audioSec / 60, unitType: "audio_min" });
10402
+ void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: durationMs / 1e3, unitType: "compute_sec" });
10282
10403
  return {
10283
10404
  text,
10284
10405
  chunks,
@@ -11753,6 +11874,7 @@ redditApp.post("/thread", createApiKeyAuth(), async (c) => {
11753
11874
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
11754
11875
  let debited = false;
11755
11876
  let refunded = false;
11877
+ let commentDebitMc = 0;
11756
11878
  try {
11757
11879
  const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD, body.url);
11758
11880
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.reddit_thread), 402);
@@ -12070,6 +12192,21 @@ videoApp.post("/status", createApiKeyAuth(), async (c) => {
12070
12192
  effectiveFrames: st.frameCount ?? null
12071
12193
  };
12072
12194
  }
12195
+ if ((st.status === "done" || st.status === "failed") && typeof st.costUsd === "number" && st.costUsd > 0) {
12196
+ const llmOp = `${LedgerOperation.VIDEO_ANALYSIS_LLM}:${runId}`;
12197
+ if (!await ledgerExistsForOperation(user.id, llmOp)) {
12198
+ await runWithCostContext({ ...currentCostContext(), op: "video_analysis", userId: user.id }, async () => {
12199
+ void recordVendorUsage({ vendor: "mcp_memory_video", model: null, units: st.costUsd, unitType: "usd", error: null });
12200
+ });
12201
+ const surchargeMc = Math.round(st.costUsd * VIDEO_ANALYSIS_LLM_MARGIN_MULTIPLE * MC_PER_USD);
12202
+ if (surchargeMc > 0) {
12203
+ const { ok } = await debitMc(user.id, surchargeMc, llmOp, `real-cost-surcharge:${runId}`);
12204
+ if (!ok) {
12205
+ console.warn(`[video-routes] VIDEO_ANALYSIS_LLM surcharge debit failed for user ${user.id} (run ${runId}): insufficient balance for ${surchargeMc}mc`);
12206
+ }
12207
+ }
12208
+ }
12209
+ }
12073
12210
  return c.json({
12074
12211
  ok: true,
12075
12212
  runId,
@@ -13240,7 +13377,7 @@ trustpilotApp.post("/reviews", createApiKeyAuth(), async (c) => {
13240
13377
  let pagesFetched = 0;
13241
13378
  try {
13242
13379
  await driver.launch({
13243
- headless: true,
13380
+ headless: false,
13244
13381
  kernelApiKey: browserServiceApiKey(),
13245
13382
  viewport: { width: 1280, height: 900 },
13246
13383
  locale: "en-US"
@@ -13433,7 +13570,7 @@ g2App.post("/reviews", createApiKeyAuth(), async (c) => {
13433
13570
  const driver = new BrowserDriver();
13434
13571
  try {
13435
13572
  await driver.launch({
13436
- headless: true,
13573
+ headless: false,
13437
13574
  kernelApiKey: browserServiceApiKey(),
13438
13575
  kernelProxyId: proxyId,
13439
13576
  viewport: { width: 1280, height: 900 },
@@ -19769,6 +19906,71 @@ import { createMiddleware as createMiddleware3 } from "hono/factory";
19769
19906
  import { getCookie as getCookie2, setCookie as setCookie2, deleteCookie } from "hono/cookie";
19770
19907
  import Stripe2 from "stripe";
19771
19908
 
19909
+ // src/api/page-diff.ts
19910
+ import { createHash as createHash4 } from "crypto";
19911
+ import { diffLines } from "diff";
19912
+ var MAX_SNAPSHOT_CONTENT_CHARS = 25e4;
19913
+ var MAX_DIFF_HUNKS = 200;
19914
+ var MAX_DIFF_LINES_PER_RESPONSE = 2e3;
19915
+ function sha256Hex(value) {
19916
+ return createHash4("sha256").update(value).digest("hex");
19917
+ }
19918
+ function truncateForStorage(value, maxChars = MAX_SNAPSHOT_CONTENT_CHARS) {
19919
+ if (value.length <= maxChars) return { value, truncated: false };
19920
+ return { value: value.slice(0, maxChars), truncated: true };
19921
+ }
19922
+ function changeLines(change) {
19923
+ const lines = change.value.split("\n");
19924
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
19925
+ return lines;
19926
+ }
19927
+ function diffPageContent(oldContent, newContent) {
19928
+ const changes = diffLines(oldContent, newContent);
19929
+ const rawHunks = [];
19930
+ let linesAdded = 0;
19931
+ let linesRemoved = 0;
19932
+ let totalChangedLineCount = 0;
19933
+ for (const change of changes) {
19934
+ if (!change.added && !change.removed) continue;
19935
+ const lines = changeLines(change);
19936
+ totalChangedLineCount += lines.length;
19937
+ if (change.added) linesAdded += lines.length;
19938
+ if (change.removed) linesRemoved += lines.length;
19939
+ rawHunks.push({ type: change.added ? "added" : "removed", lines });
19940
+ }
19941
+ let hunksTruncated = false;
19942
+ const hunksWithinCount = rawHunks.length > MAX_DIFF_HUNKS ? rawHunks.slice(0, MAX_DIFF_HUNKS) : rawHunks;
19943
+ if (rawHunks.length > MAX_DIFF_HUNKS) hunksTruncated = true;
19944
+ const hunks = [];
19945
+ let emittedLines = 0;
19946
+ for (const hunk of hunksWithinCount) {
19947
+ if (emittedLines >= MAX_DIFF_LINES_PER_RESPONSE) {
19948
+ hunksTruncated = true;
19949
+ break;
19950
+ }
19951
+ const remaining = MAX_DIFF_LINES_PER_RESPONSE - emittedLines;
19952
+ if (hunk.lines.length > remaining) {
19953
+ hunks.push({ type: hunk.type, lines: hunk.lines.slice(0, remaining) });
19954
+ emittedLines += remaining;
19955
+ hunksTruncated = true;
19956
+ break;
19957
+ }
19958
+ hunks.push(hunk);
19959
+ emittedLines += hunk.lines.length;
19960
+ }
19961
+ const denominator = Math.max(oldContent.split("\n").length, newContent.split("\n").length, 1);
19962
+ const percentChanged = totalChangedLineCount > 0 ? Math.min(100, Math.round(totalChangedLineCount / denominator * 1e3) / 10) : 0;
19963
+ return {
19964
+ hunks,
19965
+ linesAdded,
19966
+ linesRemoved,
19967
+ percentChanged,
19968
+ hunksTruncated,
19969
+ hunksTruncatedReason: hunksTruncated ? `Showing ${emittedLines} of ${totalChangedLineCount} changed lines (capped at ${MAX_DIFF_HUNKS} hunks / ${MAX_DIFF_LINES_PER_RESPONSE} lines). Use contentHash/previousContentHash to confirm the full scope of change.` : null,
19970
+ totalChangedLineCount
19971
+ };
19972
+ }
19973
+
19772
19974
  // src/api/scrape-vault-sink.ts
19773
19975
  var MAX_DEPOSIT_CHARS = 5e6;
19774
19976
  var SCRAPE_FALLBACK_PREFIX = "scrape-fallback/";
@@ -20207,9 +20409,12 @@ function opForCostPath(p) {
20207
20409
  }
20208
20410
  if (p.startsWith("/instagram/")) return "instagram";
20209
20411
  if (p === "/reddit/thread") return "reddit_thread";
20412
+ if (p.startsWith("/trustpilot/")) return "trustpilot_reviews";
20413
+ if (p.startsWith("/g2/")) return "g2_reviews";
20210
20414
  if (p.startsWith("/api/internal/site-architecture-auditor")) return "audit_site";
20211
20415
  if (p.startsWith("/api/internal/memory")) return "memory_ai";
20212
20416
  if (p.startsWith("/agent/")) return "browser_session";
20417
+ if (p.startsWith("/video/")) return "video_analysis";
20213
20418
  return null;
20214
20419
  }
20215
20420
  app.use("*", (c, next) => {
@@ -20921,16 +21126,20 @@ app.post("/harvest/sync", auth2, async (c) => {
20921
21126
  AbortSignal.timeout(syncTimeoutMs)
20922
21127
  ]);
20923
21128
  try {
20924
- const result = await harvest({
20925
- ...options,
20926
- kernelApiKey: browserServiceApiKey(),
20927
- headless: true,
20928
- format: "json",
20929
- outputDir: "/tmp/paa-output-api",
20930
- signal: syncSignal,
20931
- softDeadlineMs: Date.now() + Math.floor(syncTimeoutMs * 0.8),
20932
- onAttemptEvent: recordAttempt
20933
- });
21129
+ const harvestCtx = currentCostContext();
21130
+ const result = await runWithCostContext(
21131
+ { ...harvestCtx, op: options.serpOnly ? "serp" : "paa", userId: user.id },
21132
+ () => harvest({
21133
+ ...options,
21134
+ kernelApiKey: browserServiceApiKey(),
21135
+ headless: true,
21136
+ format: "json",
21137
+ outputDir: "/tmp/paa-output-api",
21138
+ signal: syncSignal,
21139
+ softDeadlineMs: Date.now() + Math.floor(syncTimeoutMs * 0.8),
21140
+ onAttemptEvent: recordAttempt
21141
+ })
21142
+ );
20934
21143
  await completeJob(jobId, result);
20935
21144
  const attempts = await listHarvestAttempts(jobId, user.id);
20936
21145
  if (!options.serpOnly) {
@@ -21098,6 +21307,91 @@ app.post("/extract-url", auth2, async (c) => {
21098
21307
  await releaseConcurrencyGate(gate.lockId);
21099
21308
  }
21100
21309
  });
21310
+ app.post("/diff-page", auth2, async (c) => {
21311
+ const raw = await c.req.json().catch(() => ({}));
21312
+ const bodyResult = DiffPageBodySchema.safeParse(raw);
21313
+ if (!bodyResult.success) return c.json({ error: bodyResult.error.issues[0]?.message ?? "Invalid request" }, 400);
21314
+ const { url, allowLocal, resetBaseline } = bodyResult.data;
21315
+ if (!allowLocal) {
21316
+ const checked = await validatePublicHttpUrl(url, { field: "URL" });
21317
+ if (checked.error || !checked.parsed) return c.json({ error: checked.error ?? "Invalid URL" }, 400);
21318
+ } else {
21319
+ try {
21320
+ new URL(url);
21321
+ } catch {
21322
+ return c.json({ error: "Invalid URL" }, 400);
21323
+ }
21324
+ }
21325
+ const canonicalUrl = (() => {
21326
+ try {
21327
+ return new URL(url).href;
21328
+ } catch {
21329
+ return url;
21330
+ }
21331
+ })();
21332
+ const user = c.get("user");
21333
+ const gate = await acquireConcurrencyGate(user, "diff_page", {
21334
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
21335
+ metadata: { url: canonicalUrl }
21336
+ });
21337
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
21338
+ let debited = false;
21339
+ try {
21340
+ const { ok: dpOk, balance_mc: dpBal } = await debitMc(user.id, MC_COSTS.diff_page, LedgerOperation.DIFF_PAGE, new URL(canonicalUrl).hostname);
21341
+ if (!dpOk) return c.json(insufficientBalanceResponse(dpBal, MC_COSTS.diff_page), 402);
21342
+ debited = true;
21343
+ const existingSnapshot = await getPageSnapshot(user.id, canonicalUrl);
21344
+ const previous = resetBaseline ? null : existingSnapshot;
21345
+ const kernelApiKey = browserServiceApiKey();
21346
+ const result = await extractKpo({ url: canonicalUrl, kernelApiKey });
21347
+ const currentContent = result.bodyMarkdown ?? "";
21348
+ const currentHash = sha256Hex(currentContent);
21349
+ const { value: storedContent, truncated: contentTruncated } = truncateForStorage(currentContent);
21350
+ const currentCheckedAt = (/* @__PURE__ */ new Date()).toISOString();
21351
+ await upsertPageSnapshot(user.id, canonicalUrl, {
21352
+ contentHash: currentHash,
21353
+ content: storedContent,
21354
+ title: result.title ?? null,
21355
+ contentBytes: Buffer.byteLength(currentContent, "utf8"),
21356
+ truncated: contentTruncated
21357
+ });
21358
+ let status;
21359
+ let diff = { hunks: [], linesAdded: 0, linesRemoved: 0, percentChanged: 0, hunksTruncated: false, hunksTruncatedReason: null, totalChangedLineCount: 0 };
21360
+ if (!previous) {
21361
+ status = "baseline";
21362
+ diff.percentChanged = null;
21363
+ } else if (previous.contentHash === currentHash) {
21364
+ status = "unchanged";
21365
+ } else {
21366
+ status = "changed";
21367
+ diff = diffPageContent(previous.content, storedContent);
21368
+ }
21369
+ await logRequestEvent({ userId: user.id, source: "diff_page", status: "done", query: canonicalUrl, result: { status } });
21370
+ return c.json({
21371
+ url: canonicalUrl,
21372
+ title: result.title ?? null,
21373
+ status,
21374
+ isReset: !!resetBaseline && !!existingSnapshot,
21375
+ previousCheckedAt: previous?.checkedAt ?? null,
21376
+ currentCheckedAt,
21377
+ contentHash: currentHash,
21378
+ previousContentHash: previous?.contentHash ?? null,
21379
+ summary: { linesAdded: diff.linesAdded, linesRemoved: diff.linesRemoved, percentChanged: diff.percentChanged },
21380
+ hunks: diff.hunks,
21381
+ contentTruncated,
21382
+ hunksTruncated: diff.hunksTruncated,
21383
+ hunksTruncatedReason: diff.hunksTruncatedReason,
21384
+ totalChangedLineCount: diff.totalChangedLineCount
21385
+ });
21386
+ } catch (err) {
21387
+ const msg = err instanceof Error ? err.message : String(err);
21388
+ if (debited) await creditMc(user.id, MC_COSTS.diff_page, LedgerOperation.DIFF_PAGE, "failed call");
21389
+ await logRequestEvent({ userId: user.id, source: "diff_page", status: "failed", query: canonicalUrl, error: msg });
21390
+ return c.json({ error: msg }, 500);
21391
+ } finally {
21392
+ await releaseConcurrencyGate(gate.lockId);
21393
+ }
21394
+ });
21101
21395
  app.post("/map-urls", auth2, async (c) => {
21102
21396
  const raw = await c.req.json().catch(() => ({}));
21103
21397
  const bodyResult = MapUrlsBodySchema.safeParse(raw);
@@ -21524,7 +21818,7 @@ app.get("/cron/tick", async (c) => {
21524
21818
  if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
21525
21819
  return c.json({ error: "Unauthorized" }, 401);
21526
21820
  }
21527
- const { drainQueue } = await import("./worker-YHKUJR5P.js");
21821
+ const { drainQueue } = await import("./worker-HTYZAYGB.js");
21528
21822
  const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
21529
21823
  const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
21530
21824
  const [results, sweepResult, reapResult, expiredResult, blobCleanup] = await Promise.all([
@@ -21542,15 +21836,15 @@ app.post("/api/internal/extract-refinalize/:id", async (c) => {
21542
21836
  return c.json({ error: "Unauthorized" }, 401);
21543
21837
  }
21544
21838
  const jobId = c.req.param("id");
21545
- const { getExtractJob: getExtractJob2, completeExtractJob: completeExtractJob2 } = await import("./site-extract-repository-THBVEXMP.js");
21546
- const { assembleExtractArtifacts } = await import("./extract-bundle-UKE273TV.js");
21839
+ const { getExtractJob: getExtractJob2, completeExtractJob: completeExtractJob2 } = await import("./site-extract-repository-HTIY52MW.js");
21840
+ const { assembleExtractArtifacts } = await import("./extract-bundle-COS56ZDO.js");
21547
21841
  const job = await getExtractJob2(jobId);
21548
21842
  if (!job) return c.json({ error: "job not found" }, 404);
21549
21843
  const stored = await assembleExtractArtifacts(job, {});
21550
21844
  await completeExtractJob2(jobId, stored);
21551
21845
  let settlement = "already_settled_or_refunded";
21552
21846
  if (job.billedMc == null && job.userId != null) {
21553
- const { settleExtractJob: settleExtractJob2, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-THBVEXMP.js");
21847
+ const { settleExtractJob: settleExtractJob2, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-HTIY52MW.js");
21554
21848
  const heldMc = Number(job.options.heldMc ?? 0);
21555
21849
  const successful = await countSuccessfulPages2(jobId);
21556
21850
  const usedMc = Math.min(successful * MC_COSTS.page_scrape, heldMc);
@@ -21714,4 +22008,4 @@ app.get("/blog/:slug/", (c) => {
21714
22008
  export {
21715
22009
  app
21716
22010
  };
21717
- //# sourceMappingURL=server-XCDFHHLE.js.map
22011
+ //# sourceMappingURL=server-UNID3SJU.js.map