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
@@ -3617,6 +3617,7 @@ __export(db_exports, {
3617
3617
  getLedger: () => getLedger,
3618
3618
  getMemoryPlan: () => getMemoryPlan,
3619
3619
  getMemoryProvisioned: () => getMemoryProvisioned,
3620
+ getPageSnapshot: () => getPageSnapshot,
3620
3621
  getRefresh: () => getRefresh,
3621
3622
  getUserByApiKey: () => getUserByApiKey,
3622
3623
  getUserByEmail: () => getUserByEmail,
@@ -3672,6 +3673,7 @@ __export(db_exports, {
3672
3673
  startHarvestAttempt: () => startHarvestAttempt,
3673
3674
  stripeEventAlreadyProcessed: () => stripeEventAlreadyProcessed,
3674
3675
  updateKpoJobState: () => updateKpoJobState,
3676
+ upsertPageSnapshot: () => upsertPageSnapshot,
3675
3677
  verifyPassword: () => verifyPassword
3676
3678
  });
3677
3679
  function getDb() {
@@ -4109,6 +4111,20 @@ async function migrate() {
4109
4111
  } catch {
4110
4112
  }
4111
4113
  }
4114
+ await db.execute(`
4115
+ CREATE TABLE IF NOT EXISTS page_snapshots (
4116
+ user_id INTEGER NOT NULL REFERENCES users(id),
4117
+ url TEXT NOT NULL,
4118
+ content_hash TEXT NOT NULL,
4119
+ content TEXT NOT NULL,
4120
+ title TEXT,
4121
+ content_bytes INTEGER NOT NULL,
4122
+ truncated INTEGER NOT NULL DEFAULT 0,
4123
+ checked_at TEXT NOT NULL DEFAULT (datetime('now')),
4124
+ PRIMARY KEY (user_id, url)
4125
+ )
4126
+ `);
4127
+ await db.execute(`CREATE INDEX IF NOT EXISTS page_snapshots_user_checked_at ON page_snapshots(user_id, checked_at DESC)`);
4112
4128
  }
4113
4129
  async function registerClient(clientId, redirectUris, name) {
4114
4130
  await getDb().execute({
@@ -4539,6 +4555,38 @@ async function listJobs(userId) {
4539
4555
  const res = await getDb().execute({ sql: "SELECT * FROM jobs WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", args: [userId] });
4540
4556
  return res.rows.map((r) => deserialize(rowToRawJob(r)));
4541
4557
  }
4558
+ async function getPageSnapshot(userId, url) {
4559
+ const res = await getDb().execute({
4560
+ sql: "SELECT content_hash, content, title, content_bytes, truncated, checked_at FROM page_snapshots WHERE user_id = ? AND url = ?",
4561
+ args: [userId, url]
4562
+ });
4563
+ const row = res.rows[0];
4564
+ if (!row) return null;
4565
+ return {
4566
+ contentHash: String(row.content_hash),
4567
+ content: String(row.content),
4568
+ title: row.title == null ? null : String(row.title),
4569
+ contentBytes: Number(row.content_bytes),
4570
+ truncated: Number(row.truncated) === 1,
4571
+ checkedAt: String(row.checked_at)
4572
+ };
4573
+ }
4574
+ async function upsertPageSnapshot(userId, url, snapshot) {
4575
+ await getDb().execute({
4576
+ sql: `
4577
+ INSERT INTO page_snapshots (user_id, url, content_hash, content, title, content_bytes, truncated, checked_at)
4578
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
4579
+ ON CONFLICT(user_id, url) DO UPDATE SET
4580
+ content_hash = excluded.content_hash,
4581
+ content = excluded.content,
4582
+ title = excluded.title,
4583
+ content_bytes = excluded.content_bytes,
4584
+ truncated = excluded.truncated,
4585
+ checked_at = excluded.checked_at
4586
+ `,
4587
+ args: [userId, url, snapshot.contentHash, snapshot.content, snapshot.title, snapshot.contentBytes, snapshot.truncated ? 1 : 0]
4588
+ });
4589
+ }
4542
4590
  function rowToRequestEvent(row) {
4543
4591
  const rawResult = row.result != null ? String(row.result) : null;
4544
4592
  return {
@@ -5293,18 +5341,25 @@ function kernelCostUsd(ms, headful) {
5293
5341
  return sec * (headful ? KERNEL_HEADFUL_USD_PER_SEC : KERNEL_HEADLESS_USD_PER_SEC);
5294
5342
  }
5295
5343
  function vendorCostUsd(vendor, units) {
5296
- if (vendor === "fal_wizper") return Math.max(0, units) * FAL_WIZPER_USD_PER_AUDIO_MIN;
5344
+ if (vendor === "fal_wizper") {
5345
+ const estimatedComputeSec = Math.max(0, units) / FAL_WIZPER_WALLCLOCK_TO_COMPUTE_SEC_FACTOR;
5346
+ return estimatedComputeSec * FAL_WIZPER_USD_PER_COMPUTE_SEC;
5347
+ }
5297
5348
  if (vendor === "deepinfra_qwen") return Math.max(0, units) / 1e3 * DEEPINFRA_QWEN_USD_PER_1K_OUT_TOKENS;
5349
+ if (vendor === "openrouter") return Math.max(0, units);
5350
+ if (vendor === "mcp_memory_video") return Math.max(0, units);
5351
+ if (vendor === "mcp_memory_ai") return Math.max(0, units);
5298
5352
  return 0;
5299
5353
  }
5300
- var KERNEL_HEADLESS_USD_PER_SEC, KERNEL_HEADFUL_USD_PER_SEC, HEADLESS_OPS, FAL_WIZPER_USD_PER_AUDIO_MIN, DEEPINFRA_QWEN_USD_PER_1K_OUT_TOKENS;
5354
+ var KERNEL_HEADLESS_USD_PER_SEC, KERNEL_HEADFUL_USD_PER_SEC, HEADLESS_OPS, FAL_WIZPER_USD_PER_COMPUTE_SEC, FAL_WIZPER_WALLCLOCK_TO_COMPUTE_SEC_FACTOR, DEEPINFRA_QWEN_USD_PER_1K_OUT_TOKENS;
5301
5355
  var init_cost_rates = __esm({
5302
5356
  "src/api/cost-rates.ts"() {
5303
5357
  "use strict";
5304
5358
  KERNEL_HEADLESS_USD_PER_SEC = 166667e-10;
5305
5359
  KERNEL_HEADFUL_USD_PER_SEC = 1333336e-10;
5306
- HEADLESS_OPS = /* @__PURE__ */ new Set(["harvest", "maps_search", "yt_channel", "fb_search", "fb_ad", "instagram"]);
5307
- FAL_WIZPER_USD_PER_AUDIO_MIN = 5e-3;
5360
+ HEADLESS_OPS = /* @__PURE__ */ new Set(["serp", "maps_search", "yt_channel", "yt_transcription", "fb_search", "fb_ad", "instagram"]);
5361
+ FAL_WIZPER_USD_PER_COMPUTE_SEC = 111e-5;
5362
+ FAL_WIZPER_WALLCLOCK_TO_COMPUTE_SEC_FACTOR = 4;
5308
5363
  DEEPINFRA_QWEN_USD_PER_1K_OUT_TOKENS = 4e-4;
5309
5364
  }
5310
5365
  });
@@ -5345,6 +5400,10 @@ async function migrateCostTelemetry() {
5345
5400
  await db.execute(`ALTER TABLE kernel_session_log ADD COLUMN proxy_type TEXT`);
5346
5401
  } catch {
5347
5402
  }
5403
+ try {
5404
+ await db.execute(`ALTER TABLE kernel_session_log ADD COLUMN method TEXT`);
5405
+ } catch {
5406
+ }
5348
5407
  await db.execute(`
5349
5408
  CREATE TABLE IF NOT EXISTS vendor_usage_log (
5350
5409
  id TEXT PRIMARY KEY,
@@ -5363,6 +5422,10 @@ async function migrateCostTelemetry() {
5363
5422
  await db.execute(`CREATE INDEX IF NOT EXISTS vendor_usage_log_op ON vendor_usage_log(op)`);
5364
5423
  await db.execute(`CREATE INDEX IF NOT EXISTS vendor_usage_log_probe ON vendor_usage_log(probe_run_id)`);
5365
5424
  await db.execute(`CREATE INDEX IF NOT EXISTS vendor_usage_log_created ON vendor_usage_log(created_at)`);
5425
+ try {
5426
+ await db.execute(`ALTER TABLE vendor_usage_log ADD COLUMN method TEXT`);
5427
+ } catch {
5428
+ }
5366
5429
  await db.execute(`
5367
5430
  CREATE TABLE IF NOT EXISTS cost_probe_runs (
5368
5431
  id TEXT PRIMARY KEY,
@@ -5409,8 +5472,8 @@ async function recordKernelSession(r) {
5409
5472
  const db = getDb();
5410
5473
  await db.execute({
5411
5474
  sql: `INSERT INTO kernel_session_log
5412
- (id, kernel_session_id, op, source, probe_run_id, user_id, stealth, headless_sent, proxy_used, proxy_source, proxy_type, fallback, opened_at, closed_at, duration_ms, est_cost_usd_headless, est_cost_usd_headful, error)
5413
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
5475
+ (id, kernel_session_id, op, source, probe_run_id, user_id, stealth, headless_sent, proxy_used, proxy_source, proxy_type, fallback, opened_at, closed_at, duration_ms, est_cost_usd_headless, est_cost_usd_headful, error, method)
5476
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
5414
5477
  args: [
5415
5478
  (0, import_node_crypto2.randomUUID)(),
5416
5479
  r.kernelSessionId ?? null,
@@ -5429,7 +5492,8 @@ async function recordKernelSession(r) {
5429
5492
  durationMs,
5430
5493
  kernelCostUsd(durationMs, false),
5431
5494
  kernelCostUsd(durationMs, true),
5432
- r.error ?? null
5495
+ r.error ?? null,
5496
+ ctx?.subOp ?? null
5433
5497
  ]
5434
5498
  });
5435
5499
  } catch (err) {
@@ -5443,8 +5507,8 @@ async function recordVendorUsage(r) {
5443
5507
  const db = getDb();
5444
5508
  await db.execute({
5445
5509
  sql: `INSERT INTO vendor_usage_log
5446
- (id, op, probe_run_id, user_id, vendor, model, units, unit_type, est_cost_usd, error)
5447
- VALUES (?,?,?,?,?,?,?,?,?,?)`,
5510
+ (id, op, probe_run_id, user_id, vendor, model, units, unit_type, est_cost_usd, error, method)
5511
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
5448
5512
  args: [
5449
5513
  (0, import_node_crypto2.randomUUID)(),
5450
5514
  ctx?.op ?? null,
@@ -5455,7 +5519,8 @@ async function recordVendorUsage(r) {
5455
5519
  r.units,
5456
5520
  r.unitType,
5457
5521
  vendorCostUsd(r.vendor, r.units),
5458
- r.error ?? null
5522
+ r.error ?? null,
5523
+ ctx?.subOp ?? null
5459
5524
  ]
5460
5525
  });
5461
5526
  } catch (err) {
@@ -7357,6 +7422,7 @@ var init_llm_parse_with_retry = __esm({
7357
7422
  DeepInfraLlmClient = class {
7358
7423
  apiKey;
7359
7424
  model;
7425
+ totalCostUsd = 0;
7360
7426
  constructor(apiKey, model = "Qwen/Qwen3.6-35B-A3B") {
7361
7427
  this.apiKey = apiKey;
7362
7428
  this.model = model;
@@ -7389,6 +7455,7 @@ ${raw}`
7389
7455
  OpenRouterLlmClient = class {
7390
7456
  apiKey;
7391
7457
  model;
7458
+ totalCostUsd = 0;
7392
7459
  constructor(apiKey, model = "qwen/qwen3-235b-a22b") {
7393
7460
  this.apiKey = apiKey;
7394
7461
  this.model = model;
@@ -7401,6 +7468,9 @@ ${raw}`
7401
7468
  });
7402
7469
  if (!resp.ok) throw new Error(`OpenRouter error ${resp.status}: ${await resp.text()}`);
7403
7470
  const data = await resp.json();
7471
+ const cost = data.usage?.cost ?? 0;
7472
+ this.totalCostUsd += cost;
7473
+ void recordVendorUsage({ vendor: "openrouter", model: this.model, units: cost, unitType: "usd" });
7404
7474
  return data.choices[0].message.content;
7405
7475
  }
7406
7476
  async completeJson(prompt) {
@@ -7426,6 +7496,9 @@ ${raw}`
7426
7496
  secondary;
7427
7497
  lastServedBy = null;
7428
7498
  lastPrimaryError = null;
7499
+ get totalCostUsd() {
7500
+ return this.primary.totalCostUsd + this.secondary.totalCostUsd;
7501
+ }
7429
7502
  async complete(prompt) {
7430
7503
  try {
7431
7504
  const result = await this.primary.complete(prompt);
@@ -7880,6 +7953,390 @@ var init_schemas = __esm({
7880
7953
  }
7881
7954
  });
7882
7955
 
7956
+ // src/api/rates.ts
7957
+ function browserActiveCostMc(activeMs) {
7958
+ return Math.round(activeMs * MC_PER_BROWSER_MS);
7959
+ }
7960
+ function concurrencySlotBillingInfo() {
7961
+ const billingUrl = process.env.CONCURRENCY_BILLING_URL ?? "https://mcpscraper.dev/billing";
7962
+ return {
7963
+ product: "Extra concurrency slot",
7964
+ price_label: CONCURRENCY_SLOT_PRICE_LABEL,
7965
+ unit_amount_usd: CONCURRENCY_SLOT_PRICE_USD,
7966
+ currency: CONCURRENCY_SLOT_PRICE_CURRENCY,
7967
+ interval: CONCURRENCY_SLOT_PRICE_INTERVAL,
7968
+ billing_url: billingUrl,
7969
+ terminal_command: CONCURRENCY_SLOT_TERMINAL_COMMAND,
7970
+ terminal_command_with_api_key_env: `MCP_SCRAPER_API_KEY=sk_live_your_key ${CONCURRENCY_SLOT_TERMINAL_COMMAND}`
7971
+ };
7972
+ }
7973
+ function mcToCredits(mc) {
7974
+ return mc / MC_PER_CREDIT;
7975
+ }
7976
+ function insufficientBalanceResponse(balanceMc, requiredMc) {
7977
+ const topupUrl = process.env.TOPUP_URL ?? "https://mcpscraper.dev/billing";
7978
+ const balanceCredits = mcToCredits(balanceMc);
7979
+ const requiredCredits = mcToCredits(requiredMc);
7980
+ return {
7981
+ error: "insufficient_balance",
7982
+ error_code: "insufficient_balance",
7983
+ message: `Insufficient credits. Balance: ${balanceCredits} credits. This call requires ${requiredCredits} credits. Top up at ${topupUrl}`,
7984
+ balance_credits: balanceCredits,
7985
+ required_credits: requiredCredits,
7986
+ topup_url: topupUrl
7987
+ };
7988
+ }
7989
+ var MC_COSTS, MC_PER_BROWSER_MS, BROWSER_OPEN_MIN_BALANCE_MC, MC_PER_CREDIT, CREDIT_COST_CATALOG, CONCURRENCY_PRICE_ID, SUBSCRIPTION_TIERS, SUBSCRIPTION_TIER_BY_KEY, CONCURRENCY_SLOT_PRICE_USD, CONCURRENCY_SLOT_PRICE_CURRENCY, CONCURRENCY_SLOT_PRICE_INTERVAL, CONCURRENCY_SLOT_PRICE_LABEL, CONCURRENCY_SLOT_TERMINAL_COMMAND, FREE_SIGNUP_MC, MEMORY_FREE_QUOTA_BYTES, MEMORY_PLANS, MEMORY_PLAN_QUOTA, MEMORY_MARGIN_MULTIPLE, MEMORY_PRO_COST_BUDGET_USD, SCHEDULING_PLANS, LedgerOperation, MEMORY_AI_MARGIN_MULTIPLE, SITE_AUDIT_LLM_MARGIN_MULTIPLE, VIDEO_ANALYSIS_LLM_MARGIN_MULTIPLE, MC_PER_USD;
7990
+ var init_rates = __esm({
7991
+ "src/api/rates.ts"() {
7992
+ "use strict";
7993
+ MC_COSTS = {
7994
+ serp: 100,
7995
+ paa: 200,
7996
+ paa_base: 1e3,
7997
+ page_scrape: 100,
7998
+ url_map: 500,
7999
+ yt_channel: 200,
8000
+ yt_transcription: 200,
8001
+ fb_ad: 200,
8002
+ maps_search: 500,
8003
+ maps_place: 6e3,
8004
+ maps_review: 100,
8005
+ fb_search: 600,
8006
+ fb_transcribe: 200,
8007
+ google_ads_search: 600,
8008
+ google_ads_intel: 200,
8009
+ google_ads_transcribe: 200,
8010
+ instagram_profile: 400,
8011
+ instagram_media: 400,
8012
+ instagram_transcribe: 50,
8013
+ browser_minute: 12e3,
8014
+ reddit_thread: 3e3,
8015
+ reddit_comment: 200,
8016
+ video_analysis: 666667,
8017
+ trustpilot_reviews: 500,
8018
+ trustpilot_review: 100,
8019
+ g2_reviews: 500,
8020
+ g2_review: 150,
8021
+ diff_page: 100
8022
+ };
8023
+ MC_PER_BROWSER_MS = MC_COSTS.browser_minute / 6e4;
8024
+ BROWSER_OPEN_MIN_BALANCE_MC = 1e3;
8025
+ MC_PER_CREDIT = 1e3;
8026
+ CREDIT_COST_CATALOG = [
8027
+ {
8028
+ key: "serp",
8029
+ label: "SERP search",
8030
+ aliases: ["search_serp", "serp", "google search", "organic results"],
8031
+ credits: mcToCredits(MC_COSTS.serp),
8032
+ unit: "per search",
8033
+ notes: "Returns AI Overview, PAA snippet, videos, forums, and local pack."
8034
+ },
8035
+ {
8036
+ key: "paa",
8037
+ label: "PAA harvest",
8038
+ aliases: ["harvest_paa", "paa", "people also ask", "questions"],
8039
+ credits: mcToCredits(MC_COSTS.paa),
8040
+ unit: `per question (+${mcToCredits(MC_COSTS.paa_base)} credit base)`,
8041
+ notes: `Full SERP feature extraction. Billed ${mcToCredits(MC_COSTS.paa_base)} credit base + ${mcToCredits(MC_COSTS.paa)} per question actually returned (unused estimate refunded).`
8042
+ },
8043
+ {
8044
+ key: "page_scrape",
8045
+ label: "Page crawl / extract",
8046
+ aliases: ["extract_url", "extract_site", "page scrape", "url scrape", "single page", "site crawl"],
8047
+ credits: mcToCredits(MC_COSTS.page_scrape),
8048
+ unit: "per page",
8049
+ notes: "Applies to both single-URL extraction and per-page site crawls."
8050
+ },
8051
+ {
8052
+ key: "url_map",
8053
+ label: "Site URL mapping",
8054
+ aliases: ["map_site_urls", "url map", "site map", "crawl urls"],
8055
+ credits: mcToCredits(MC_COSTS.url_map),
8056
+ unit: "per mapping operation",
8057
+ notes: "Flat rate for the full /map-urls call regardless of URL count discovered."
8058
+ },
8059
+ {
8060
+ key: "yt_channel",
8061
+ label: "YouTube search / channel harvest",
8062
+ aliases: ["youtube_harvest", "youtube search", "youtube channel", "yt_channel"],
8063
+ credits: mcToCredits(MC_COSTS.yt_channel),
8064
+ unit: "per call"
8065
+ },
8066
+ {
8067
+ key: "yt_transcription",
8068
+ label: "YouTube transcription",
8069
+ aliases: ["youtube_transcribe", "youtube transcript", "transcription", "yt_transcription"],
8070
+ credits: mcToCredits(MC_COSTS.yt_transcription),
8071
+ unit: "per minute",
8072
+ notes: "A 5-minute hold is taken, then reconciled to actual video duration."
8073
+ },
8074
+ {
8075
+ key: "fb_ad",
8076
+ label: "Facebook search / ad lookup",
8077
+ aliases: ["facebook_page_intel", "facebook_ad_search", "facebook_ad", "facebook ads", "fb ads"],
8078
+ credits: mcToCredits(MC_COSTS.fb_ad),
8079
+ unit: "per call"
8080
+ },
8081
+ {
8082
+ key: "maps_search",
8083
+ label: "Maps business search",
8084
+ aliases: ["maps_search", "google maps search", "gmb search", "gbp search", "business profiles"],
8085
+ credits: mcToCredits(MC_COSTS.maps_search),
8086
+ unit: "per search",
8087
+ notes: "Returns up to 50 Google Maps business/profile candidates. Use maps_place_intel to hydrate selected businesses."
8088
+ },
8089
+ {
8090
+ key: "maps_place",
8091
+ label: "Maps business lookup",
8092
+ aliases: ["maps_place_intel", "google maps", "maps place", "place intel"],
8093
+ credits: mcToCredits(MC_COSTS.maps_place),
8094
+ unit: "per business",
8095
+ notes: "Base lookup. Reviews billed separately per card at maps_review rate."
8096
+ },
8097
+ {
8098
+ key: "maps_review",
8099
+ label: "Maps review",
8100
+ aliases: ["maps_reviews", "google reviews", "review cards", "reviews"],
8101
+ credits: mcToCredits(MC_COSTS.maps_review),
8102
+ unit: "per review card",
8103
+ notes: "Charged after extraction when includeReviews is true."
8104
+ },
8105
+ {
8106
+ key: "fb_search",
8107
+ label: "Facebook ad library search",
8108
+ aliases: ["facebook_search", "fb_search", "fb ad search"],
8109
+ credits: mcToCredits(MC_COSTS.fb_search),
8110
+ unit: "per search",
8111
+ notes: "Browser automation to search Facebook Ads Library by keyword."
8112
+ },
8113
+ {
8114
+ key: "google_ads_search",
8115
+ label: "Google Ads Transparency search",
8116
+ aliases: ["google_ads_search", "google ads search", "ads transparency search"],
8117
+ credits: mcToCredits(MC_COSTS.google_ads_search),
8118
+ unit: "per search",
8119
+ notes: "Browser automation to find advertisers in Google Ads Transparency Center by domain or name."
8120
+ },
8121
+ {
8122
+ key: "google_ads_intel",
8123
+ label: "Google Ads Transparency advertiser intel",
8124
+ aliases: ["google_ads_page_intel", "google ads intel", "ads transparency intel"],
8125
+ credits: mcToCredits(MC_COSTS.google_ads_intel),
8126
+ unit: "per call",
8127
+ notes: "Lists and hydrates an advertiser's creatives with image URLs and video references."
8128
+ },
8129
+ {
8130
+ key: "google_ads_transcribe",
8131
+ label: "Google ad video transcription",
8132
+ aliases: ["google_ads_transcribe", "google ad transcript"],
8133
+ credits: mcToCredits(MC_COSTS.google_ads_transcribe),
8134
+ unit: "per minute",
8135
+ notes: "A hold is taken, then reconciled to actual video duration."
8136
+ },
8137
+ {
8138
+ key: "fb_transcribe",
8139
+ label: "Facebook video / ad transcription",
8140
+ aliases: ["facebook_transcribe", "facebook_video_transcribe", "fb_transcribe", "fb ad transcript"],
8141
+ credits: mcToCredits(MC_COSTS.fb_transcribe),
8142
+ unit: "per minute",
8143
+ notes: "A hold is taken, then reconciled to actual video duration."
8144
+ },
8145
+ {
8146
+ key: "instagram_profile",
8147
+ label: "Instagram profile content discovery",
8148
+ aliases: ["instagram_profile_content", "instagram profile", "instagram content list", "ig profile"],
8149
+ credits: mcToCredits(MC_COSTS.instagram_profile),
8150
+ unit: "per profile scan",
8151
+ notes: "Browser extraction of public Instagram profile grid links. Complete history may require a logged-in profile."
8152
+ },
8153
+ {
8154
+ key: "instagram_media",
8155
+ label: "Instagram media download",
8156
+ aliases: ["instagram_media_download", "instagram reel download", "instagram post download", "ig media"],
8157
+ credits: mcToCredits(MC_COSTS.instagram_media),
8158
+ unit: "per post or reel",
8159
+ notes: "Extracts post text, image URLs, and reel audio/video tracks, with local downloads when the server can write files."
8160
+ },
8161
+ {
8162
+ key: "instagram_transcribe",
8163
+ label: "Instagram media transcription",
8164
+ aliases: ["instagram transcript", "instagram reel transcript", "ig transcribe"],
8165
+ credits: mcToCredits(MC_COSTS.instagram_transcribe),
8166
+ unit: "per call",
8167
+ notes: "Whisper transcription of selected Instagram audio/video media via fal.ai."
8168
+ },
8169
+ {
8170
+ key: "browser_minute",
8171
+ label: "Interactive browser session",
8172
+ aliases: ["browser_open", "browser agent", "browser_agent", "live browser", "browse", "browser control", "interactive browser"],
8173
+ credits: mcToCredits(MC_COSTS.browser_minute),
8174
+ unit: "per minute of use",
8175
+ notes: "Metered per minute of use for the whole time the browser session is open. Close the session to stop the meter; abandoned sessions are auto-closed after a short idle window."
8176
+ },
8177
+ {
8178
+ key: "reddit_thread",
8179
+ label: "Reddit thread base lookup",
8180
+ aliases: ["reddit", "reddit_thread", "reddit comments", "subreddit", "reddit post"],
8181
+ credits: mcToCredits(MC_COSTS.reddit_thread),
8182
+ unit: "per thread",
8183
+ notes: "Base lookup for the post itself. Comments billed separately per comment at reddit_comment rate. Refunded if the thread cannot be retrieved."
8184
+ },
8185
+ {
8186
+ key: "reddit_comment",
8187
+ label: "Reddit comment",
8188
+ aliases: ["reddit_comment", "reddit comments"],
8189
+ credits: mcToCredits(MC_COSTS.reddit_comment),
8190
+ unit: "per comment",
8191
+ notes: "Charged per comment actually extracted, billed down automatically if the balance runs out mid-thread."
8192
+ },
8193
+ {
8194
+ key: "video_analysis",
8195
+ label: "Video breakdown (frame-by-frame + transcript)",
8196
+ aliases: ["video_frame_analysis", "video breakdown", "video analysis", "analyze video"],
8197
+ credits: mcToCredits(MC_COSTS.video_analysis),
8198
+ unit: "per 120 frames (max 480)",
8199
+ notes: "Full multi-lens video breakdown: samples frames across a video (up to 30 minutes), analyzes each with vision AI, transcribes the audio, then produces summary, pacing/energy, words-per-minute, topic outline, key points, hook analysis, visual style, and a how-to-replicate recipe with a quality-control pass. $1 per 120 frames requested (max 480 = $4); billed down automatically if the video cannot use the requested frames, and refunded fully if the run fails."
8200
+ },
8201
+ {
8202
+ key: "trustpilot_reviews",
8203
+ label: "Trustpilot review harvest",
8204
+ aliases: ["trustpilot_reviews", "trustpilot", "trustpilot reviews"],
8205
+ credits: mcToCredits(MC_COSTS.trustpilot_reviews),
8206
+ unit: "per call",
8207
+ notes: "Base lookup. Reviews billed separately per card at trustpilot_review rate. Refunded if no reviews are found."
8208
+ },
8209
+ {
8210
+ key: "trustpilot_review",
8211
+ label: "Trustpilot review card",
8212
+ aliases: ["trustpilot_review", "trustpilot card"],
8213
+ credits: mcToCredits(MC_COSTS.trustpilot_review),
8214
+ unit: "per review card",
8215
+ notes: "Charged per review actually extracted, billed down automatically if the balance runs out mid-page."
8216
+ },
8217
+ {
8218
+ key: "g2_reviews",
8219
+ label: "G2 review harvest",
8220
+ aliases: ["g2_reviews", "g2", "g2 reviews"],
8221
+ credits: mcToCredits(MC_COSTS.g2_reviews),
8222
+ unit: "per call",
8223
+ notes: "Base lookup. Reviews billed separately per card at g2_review rate. Refunded if no reviews are found."
8224
+ },
8225
+ {
8226
+ key: "g2_review",
8227
+ label: "G2 review card",
8228
+ aliases: ["g2_review", "g2 card"],
8229
+ credits: mcToCredits(MC_COSTS.g2_review),
8230
+ unit: "per review card",
8231
+ notes: "Charged per review actually extracted (each card carries up to 3 Q&A sections), billed down automatically if the balance runs out mid-page."
8232
+ },
8233
+ {
8234
+ key: "diff_page",
8235
+ label: "Page change check",
8236
+ aliases: ["diff_page", "page diff", "change detection"],
8237
+ credits: mcToCredits(MC_COSTS.diff_page),
8238
+ unit: "per check",
8239
+ notes: "Same cost as a single page extract \u2014 one scrape is performed per check, then compared against your last stored snapshot for that URL."
8240
+ }
8241
+ ];
8242
+ CONCURRENCY_PRICE_ID = "price_1Ta1NRS8aAcsk3TGwsRnYbix";
8243
+ SUBSCRIPTION_TIERS = {
8244
+ "price_1TmiHRS8aAcsk3TGwmSNfNIa": { tier: "starter", label: "Starter", price_id: "price_1TmiHRS8aAcsk3TGwmSNfNIa", monthly_usd: 12, credits_mc: 8e6, concurrency: 3, intro_coupon: "mcp-starter-1dollar-intro-12" },
8245
+ "price_1Tmg1nS8aAcsk3TGe8zcnGTM": { tier: "growth", label: "Growth", price_id: "price_1Tmg1nS8aAcsk3TGe8zcnGTM", monthly_usd: 100, credits_mc: 8e7, concurrency: 10, intro_coupon: null },
8246
+ "price_1Tmg1nS8aAcsk3TGsZw34iXS": { tier: "scale", label: "Scale", price_id: "price_1Tmg1nS8aAcsk3TGsZw34iXS", monthly_usd: 250, credits_mc: 24e7, concurrency: 20, intro_coupon: null }
8247
+ };
8248
+ SUBSCRIPTION_TIER_BY_KEY = Object.fromEntries(
8249
+ Object.values(SUBSCRIPTION_TIERS).map((t) => [t.tier, t])
8250
+ );
8251
+ CONCURRENCY_SLOT_PRICE_USD = 5;
8252
+ CONCURRENCY_SLOT_PRICE_CURRENCY = "usd";
8253
+ CONCURRENCY_SLOT_PRICE_INTERVAL = "month";
8254
+ CONCURRENCY_SLOT_PRICE_LABEL = "$5/month";
8255
+ CONCURRENCY_SLOT_TERMINAL_COMMAND = "npx -y -p mcp-scraper@latest mcp-scraper-cli billing concurrency checkout";
8256
+ FREE_SIGNUP_MC = 1e5;
8257
+ MEMORY_FREE_QUOTA_BYTES = 1e7 * 1;
8258
+ MEMORY_PLANS = {
8259
+ "price_1TnMSTS8aAcsk3TGBgiwuvqL": { plan: "pro", label: "Pro", price_id: "price_1TnMSTS8aAcsk3TGBgiwuvqL", interval: "month", monthly_usd: 19, quota_bytes: 5e9 },
8260
+ "price_1TnMSTS8aAcsk3TGWBVU2agY": { plan: "pro", label: "Pro", price_id: "price_1TnMSTS8aAcsk3TGWBVU2agY", interval: "year", monthly_usd: 19, quota_bytes: 5e9 }
8261
+ };
8262
+ MEMORY_PLAN_QUOTA = { free: MEMORY_FREE_QUOTA_BYTES, pro: 5e9, team: 5e10 };
8263
+ MEMORY_MARGIN_MULTIPLE = 3;
8264
+ MEMORY_PRO_COST_BUDGET_USD = 19 / MEMORY_MARGIN_MULTIPLE;
8265
+ SCHEDULING_PLANS = {
8266
+ "price_1ToXcHS8aAcsk3TGf3cW7zHx": { price_id: "price_1ToXcHS8aAcsk3TGf3cW7zHx", label: "Scheduled Actions", interval: "month", monthly_usd: 10, quota_per_period: 1e3 }
8267
+ };
8268
+ LedgerOperation = {
8269
+ TOPUP: "topup",
8270
+ SUBSCRIPTION: "subscription",
8271
+ SIGNUP_GRANT: "signup_grant",
8272
+ MONTHLY_REFRESH: "monthly_free_refresh",
8273
+ PAA: "paa",
8274
+ PAA_REFUND: "paa_refund",
8275
+ SERP: "serp",
8276
+ REFUND: "refund",
8277
+ TRANSCRIPTION: "transcription",
8278
+ TRANSCRIPTION_HOLD: "transcription_hold",
8279
+ TRANSCRIPTION_REFUND: "transcription_refund",
8280
+ YT_CHANNEL: "yt_channel",
8281
+ FB_AD: "fb_ad",
8282
+ MAPS_SEARCH: "maps_search",
8283
+ MAPS_PLACE: "maps_place",
8284
+ MAPS_REVIEW: "maps_review",
8285
+ MAPS_REVIEW_REFUND: "maps_review_refund",
8286
+ EXTRACT_SITE: "extract_site",
8287
+ EXTRACT_SITE_REFUND: "extract_site_refund",
8288
+ EXTRACT_URL: "page_scrape",
8289
+ URL_MAP: "url_map",
8290
+ EXTRACT_SITE_HOLD: "extract_site_hold",
8291
+ YT_CHANNEL_REFUND: "yt_channel_refund",
8292
+ FB_AD_REFUND: "fb_ad_refund",
8293
+ URL_MAP_REFUND: "url_map_refund",
8294
+ FB_SEARCH: "fb_search",
8295
+ FB_TRANSCRIBE: "fb_transcribe",
8296
+ FB_SEARCH_REFUND: "fb_search_refund",
8297
+ FB_TRANSCRIBE_REFUND: "fb_transcribe_refund",
8298
+ GOOGLE_ADS_SEARCH: "google_ads_search",
8299
+ GOOGLE_ADS_SEARCH_REFUND: "google_ads_search_refund",
8300
+ GOOGLE_ADS_INTEL: "google_ads_intel",
8301
+ GOOGLE_ADS_INTEL_REFUND: "google_ads_intel_refund",
8302
+ GOOGLE_ADS_TRANSCRIBE: "google_ads_transcribe",
8303
+ GOOGLE_ADS_TRANSCRIBE_REFUND: "google_ads_transcribe_refund",
8304
+ INSTAGRAM_PROFILE: "instagram_profile",
8305
+ INSTAGRAM_PROFILE_REFUND: "instagram_profile_refund",
8306
+ INSTAGRAM_MEDIA: "instagram_media",
8307
+ INSTAGRAM_MEDIA_REFUND: "instagram_media_refund",
8308
+ INSTAGRAM_TRANSCRIBE: "instagram_transcribe",
8309
+ INSTAGRAM_TRANSCRIBE_REFUND: "instagram_transcribe_refund",
8310
+ BROWSER_SESSION: "browser_session",
8311
+ REDDIT_THREAD: "reddit_thread",
8312
+ REDDIT_THREAD_REFUND: "reddit_thread_refund",
8313
+ REDDIT_COMMENT: "reddit_comment",
8314
+ REDDIT_COMMENT_REFUND: "reddit_comment_refund",
8315
+ VIDEO_ANALYSIS: "video_analysis",
8316
+ VIDEO_ANALYSIS_REFUND: "video_analysis_refund",
8317
+ VIDEO_ANALYSIS_ADJUST: "video_analysis_adjust",
8318
+ VIDEO_ANALYSIS_LLM: "video_analysis_llm",
8319
+ MEMORY_AI: "memory_ai",
8320
+ MEMORY_AI_REFUND: "memory_ai_refund",
8321
+ TRUSTPILOT_REVIEWS: "trustpilot_reviews",
8322
+ TRUSTPILOT_REVIEWS_REFUND: "trustpilot_reviews_refund",
8323
+ TRUSTPILOT_REVIEW: "trustpilot_review",
8324
+ TRUSTPILOT_REVIEW_REFUND: "trustpilot_review_refund",
8325
+ G2_REVIEWS: "g2_reviews",
8326
+ G2_REVIEWS_REFUND: "g2_reviews_refund",
8327
+ G2_REVIEW: "g2_review",
8328
+ G2_REVIEW_REFUND: "g2_review_refund",
8329
+ DIFF_PAGE: "diff_page",
8330
+ DIFF_PAGE_REFUND: "diff_page_refund",
8331
+ SITE_AUDIT_LLM: "site_audit_llm"
8332
+ };
8333
+ MEMORY_AI_MARGIN_MULTIPLE = 3;
8334
+ SITE_AUDIT_LLM_MARGIN_MULTIPLE = 1.5;
8335
+ VIDEO_ANALYSIS_LLM_MARGIN_MULTIPLE = 1.5;
8336
+ MC_PER_USD = SUBSCRIPTION_TIERS["price_1TmiHRS8aAcsk3TGwmSNfNIa"].credits_mc / SUBSCRIPTION_TIERS["price_1TmiHRS8aAcsk3TGwmSNfNIa"].monthly_usd;
8337
+ }
8338
+ });
8339
+
7883
8340
  // src/services/site-architecture-auditor/prompts/ingest-validate.ts
7884
8341
  function buildIngestValidatePrompt(input) {
7885
8342
  const sessionPath = `<session-path-from-job>`;
@@ -8903,6 +9360,9 @@ var init_site_audit_service = __esm({
8903
9360
  import_p_limit = __toESM(require("p-limit"), 1);
8904
9361
  init_llm_parse_with_retry();
8905
9362
  init_schemas();
9363
+ init_db();
9364
+ init_rates();
9365
+ init_cost_context();
8906
9366
  init_ingest_validate();
8907
9367
  init_enrich_competitor();
8908
9368
  init_build_graph_per_site();
@@ -8961,8 +9421,23 @@ var init_site_audit_service = __esm({
8961
9421
  async getJobsForUser(userId) {
8962
9422
  return this.deps.repo.listSiteAuditJobs(userId);
8963
9423
  }
9424
+ async runLlmBilled(userId, source, fn) {
9425
+ const before = this.deps.llm.totalCostUsd;
9426
+ const result = await runWithCostContext({ ...currentCostContext(), op: "audit_site", userId }, fn);
9427
+ const deltaCostUsd = this.deps.llm.totalCostUsd - before;
9428
+ if (deltaCostUsd <= 0) return result;
9429
+ const mc = Math.round(deltaCostUsd * SITE_AUDIT_LLM_MARGIN_MULTIPLE * MC_PER_USD);
9430
+ if (mc <= 0) return result;
9431
+ const { ok } = await debitMc(userId, mc, LedgerOperation.SITE_AUDIT_LLM, source);
9432
+ if (!ok) {
9433
+ console.warn(`[site-audit-service] SITE_AUDIT_LLM surcharge debit failed for user ${userId} (${source}): insufficient balance for ${mc}mc`);
9434
+ }
9435
+ return result;
9436
+ }
8964
9437
  async runIngestPhase(jobId, payload) {
8965
9438
  const { request } = payload;
9439
+ const job = await this.deps.repo.getSiteAuditJob(jobId);
9440
+ if (!job) throw new Error(`[site-audit-service] runIngestPhase: job ${jobId} not found`);
8966
9441
  const input = {
8967
9442
  clientDomain: request.clientDomain,
8968
9443
  sfInternalPath: request.sfInternalPath,
@@ -8977,14 +9452,18 @@ var init_site_audit_service = __esm({
8977
9452
  businessContext: request.businessContext,
8978
9453
  priorAuditMemoryEnabled: request.priorAuditMemoryEnabled
8979
9454
  };
8980
- const result = await llmParseWithRetry(
8981
- ValidatedFileManifestSchema,
8982
- (attempt, error) => {
8983
- void attempt;
8984
- void error;
8985
- return buildIngestValidatePrompt(input);
8986
- },
8987
- this.deps.llm
9455
+ const result = await this.runLlmBilled(
9456
+ job.user_id,
9457
+ `${jobId}:phase1-ingest`,
9458
+ () => llmParseWithRetry(
9459
+ ValidatedFileManifestSchema,
9460
+ (attempt, error) => {
9461
+ void attempt;
9462
+ void error;
9463
+ return buildIngestValidatePrompt(input);
9464
+ },
9465
+ this.deps.llm
9466
+ )
8988
9467
  );
8989
9468
  await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase1-ingest", result);
8990
9469
  }
@@ -8999,23 +9478,27 @@ var init_site_audit_service = __esm({
8999
9478
  const competitorDomainsCsvPath = request.competitorDomainsCsvPath;
9000
9479
  void competitorDomainsCsvPath;
9001
9480
  const competitors = await this._readCompetitorDomains(request);
9002
- const results = await Promise.all(
9003
- competitors.map(
9004
- (domain, idx) => limit(async () => {
9005
- const competitorIndex = idx + 1;
9006
- const rawPrompt = buildEnrichCompetitorPrompt({ domain, sessionPath });
9007
- const prompt = rawPrompt.replace("<competitor-index>", String(competitorIndex));
9008
- const result = await llmParseWithRetry(
9009
- EnrichCompetitorOutputSchema,
9010
- (attempt, error) => {
9011
- void attempt;
9012
- void error;
9013
- return prompt;
9014
- },
9015
- this.deps.llm
9016
- );
9017
- return result;
9018
- })
9481
+ const results = await this.runLlmBilled(
9482
+ job.user_id,
9483
+ `${jobId}:phase1-enrich`,
9484
+ () => Promise.all(
9485
+ competitors.map(
9486
+ (domain, idx) => limit(async () => {
9487
+ const competitorIndex = idx + 1;
9488
+ const rawPrompt = buildEnrichCompetitorPrompt({ domain, sessionPath });
9489
+ const prompt = rawPrompt.replace("<competitor-index>", String(competitorIndex));
9490
+ const result = await llmParseWithRetry(
9491
+ EnrichCompetitorOutputSchema,
9492
+ (attempt, error) => {
9493
+ void attempt;
9494
+ void error;
9495
+ return prompt;
9496
+ },
9497
+ this.deps.llm
9498
+ );
9499
+ return result;
9500
+ })
9501
+ )
9019
9502
  )
9020
9503
  );
9021
9504
  await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase1-ingest", { competitors: results });
@@ -9029,19 +9512,23 @@ var init_site_audit_service = __esm({
9029
9512
  const clientDomain = request.clientDomain;
9030
9513
  const urlInventory = [];
9031
9514
  const directedEdgeCount = 0;
9032
- const orphanAnnotation = await llmParseWithRetry(
9033
- OrphanAnnotationOutputSchema,
9034
- (attempt, error) => {
9035
- void attempt;
9036
- void error;
9037
- return buildGraphPerSitePrompt({
9038
- siteId: "client",
9039
- domain: clientDomain,
9040
- urlInventory,
9041
- directedEdgeCount
9042
- });
9043
- },
9044
- this.deps.llm
9515
+ const orphanAnnotation = await this.runLlmBilled(
9516
+ job.user_id,
9517
+ `${jobId}:phase2-build-graph`,
9518
+ () => llmParseWithRetry(
9519
+ OrphanAnnotationOutputSchema,
9520
+ (attempt, error) => {
9521
+ void attempt;
9522
+ void error;
9523
+ return buildGraphPerSitePrompt({
9524
+ siteId: "client",
9525
+ domain: clientDomain,
9526
+ urlInventory,
9527
+ directedEdgeCount
9528
+ });
9529
+ },
9530
+ this.deps.llm
9531
+ )
9045
9532
  );
9046
9533
  const graphMetrics = await this.deps.python.runGraphMetrics({
9047
9534
  directedLinkGraphPath: `${sessionPath}/phase1/directed_link_graph.json`,
@@ -9086,50 +9573,52 @@ var init_site_audit_service = __esm({
9086
9573
  }
9087
9574
  if (currentBatch.length > 0) batches.push(currentBatch);
9088
9575
  const usZipsCsvPath = process.env["US_ZIPS_CSV_PATH"] ?? "/Users/vilovieta/Downloads/sales-magician-api-leads-magician-01c6cff78e31/tools/analytics/data/uszips.csv";
9089
- for (const batch of batches) {
9090
- let classified = [];
9091
- let unclassifiedUrls = batch.map((u) => u.url);
9092
- try {
9093
- const pythonResult = await this.deps.python.runLocationClassifier({
9094
- urls: batch.map((u) => u.url),
9095
- usZipsCsvPath
9096
- });
9097
- classified = pythonResult.classified;
9098
- unclassifiedUrls = pythonResult.unclassified;
9099
- } catch (err) {
9100
- console.warn(
9101
- "[site-audit-service] runClassifyPhase: python.runLocationClassifier failed (falling back to LLM):",
9102
- err instanceof Error ? err.message : String(err)
9103
- );
9104
- unclassifiedUrls = batch.map((u) => u.url);
9105
- }
9106
- for (const c of classified) {
9107
- allClassifications.push({
9108
- url: c.url,
9109
- page_type: c.pageType,
9110
- confidence: c.confidence > 0.6 ? "high" : c.confidence > 0.4 ? "medium" : "low",
9111
- _classification_label: "[COMPUTED:python/location-classifier]",
9112
- topic_cluster: null,
9113
- location_signals: c.locationSignals
9114
- });
9115
- }
9116
- if (unclassifiedUrls.length > 0) {
9117
- const unclassifiedBatch = batch.filter((u) => unclassifiedUrls.includes(u.url));
9118
- const llmResults = await llmParseWithRetry(
9119
- import_zod10.z.array(PageTypeClassificationRowSchema),
9120
- (attempt, error) => {
9121
- void attempt;
9122
- void error;
9123
- return buildMetricsClassifyPrompt({
9124
- urls: unclassifiedBatch,
9125
- siteDomain: clientDomain
9126
- });
9127
- },
9128
- this.deps.llm
9129
- );
9130
- allClassifications.push(...llmResults);
9576
+ await this.runLlmBilled(job.user_id, `${jobId}:phase3-classify`, async () => {
9577
+ for (const batch of batches) {
9578
+ let classified = [];
9579
+ let unclassifiedUrls = batch.map((u) => u.url);
9580
+ try {
9581
+ const pythonResult = await this.deps.python.runLocationClassifier({
9582
+ urls: batch.map((u) => u.url),
9583
+ usZipsCsvPath
9584
+ });
9585
+ classified = pythonResult.classified;
9586
+ unclassifiedUrls = pythonResult.unclassified;
9587
+ } catch (err) {
9588
+ console.warn(
9589
+ "[site-audit-service] runClassifyPhase: python.runLocationClassifier failed (falling back to LLM):",
9590
+ err instanceof Error ? err.message : String(err)
9591
+ );
9592
+ unclassifiedUrls = batch.map((u) => u.url);
9593
+ }
9594
+ for (const c of classified) {
9595
+ allClassifications.push({
9596
+ url: c.url,
9597
+ page_type: c.pageType,
9598
+ confidence: c.confidence > 0.6 ? "high" : c.confidence > 0.4 ? "medium" : "low",
9599
+ _classification_label: "[COMPUTED:python/location-classifier]",
9600
+ topic_cluster: null,
9601
+ location_signals: c.locationSignals
9602
+ });
9603
+ }
9604
+ if (unclassifiedUrls.length > 0) {
9605
+ const unclassifiedBatch = batch.filter((u) => unclassifiedUrls.includes(u.url));
9606
+ const llmResults = await llmParseWithRetry(
9607
+ import_zod10.z.array(PageTypeClassificationRowSchema),
9608
+ (attempt, error) => {
9609
+ void attempt;
9610
+ void error;
9611
+ return buildMetricsClassifyPrompt({
9612
+ urls: unclassifiedBatch,
9613
+ siteDomain: clientDomain
9614
+ });
9615
+ },
9616
+ this.deps.llm
9617
+ );
9618
+ allClassifications.push(...llmResults);
9619
+ }
9131
9620
  }
9132
- }
9621
+ });
9133
9622
  void sessionPath;
9134
9623
  await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase3-classify", {
9135
9624
  classifications: allClassifications,
@@ -9142,20 +9631,24 @@ var init_site_audit_service = __esm({
9142
9631
  if (!job) throw new Error(`[site-audit-service] runComparePhase: job ${jobId} not found`);
9143
9632
  const request = JSON.parse(job.request);
9144
9633
  const clientDomain = request.clientDomain;
9145
- const result = await llmParseWithRetry(
9146
- CompareRecommendOutputSchema,
9147
- (attempt, error) => {
9148
- void attempt;
9149
- void error;
9150
- return buildCompareRecommendPrompt({
9151
- clientDomain,
9152
- clientMoneyPages: [],
9153
- competitorSites: [],
9154
- clientUrlMetrics: [],
9155
- clientPageTypes: []
9156
- });
9157
- },
9158
- this.deps.llm
9634
+ const result = await this.runLlmBilled(
9635
+ job.user_id,
9636
+ `${jobId}:phase4-compare`,
9637
+ () => llmParseWithRetry(
9638
+ CompareRecommendOutputSchema,
9639
+ (attempt, error) => {
9640
+ void attempt;
9641
+ void error;
9642
+ return buildCompareRecommendPrompt({
9643
+ clientDomain,
9644
+ clientMoneyPages: [],
9645
+ competitorSites: [],
9646
+ clientUrlMetrics: [],
9647
+ clientPageTypes: []
9648
+ });
9649
+ },
9650
+ this.deps.llm
9651
+ )
9159
9652
  );
9160
9653
  await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase4-compare", result);
9161
9654
  return result;
@@ -9185,20 +9678,24 @@ var init_site_audit_service = __esm({
9185
9678
  total: ilesTotal,
9186
9679
  components: ilesComponents
9187
9680
  };
9188
- const reportMarkdown = await llmParseWithRetry(
9189
- import_zod10.z.string(),
9190
- (attempt, error) => {
9191
- void attempt;
9192
- void error;
9193
- return buildScoreSynthesizePrompt({
9194
- preComputedArchitectureScore,
9195
- preComputedIlesScore,
9196
- rankedBacklog: [],
9197
- clientDomain,
9198
- sessionPath
9199
- });
9200
- },
9201
- this.deps.llm
9681
+ const reportMarkdown = await this.runLlmBilled(
9682
+ job.user_id,
9683
+ `${jobId}:phase5-synthesize`,
9684
+ () => llmParseWithRetry(
9685
+ import_zod10.z.string(),
9686
+ (attempt, error) => {
9687
+ void attempt;
9688
+ void error;
9689
+ return buildScoreSynthesizePrompt({
9690
+ preComputedArchitectureScore,
9691
+ preComputedIlesScore,
9692
+ rankedBacklog: [],
9693
+ clientDomain,
9694
+ sessionPath
9695
+ });
9696
+ },
9697
+ this.deps.llm
9698
+ )
9202
9699
  );
9203
9700
  const auditResult = SiteAuditResultSchema.parse({
9204
9701
  jobId,
@@ -9983,364 +10480,6 @@ var init_image_audit = __esm({
9983
10480
  }
9984
10481
  });
9985
10482
 
9986
- // src/api/rates.ts
9987
- function browserActiveCostMc(activeMs) {
9988
- return Math.round(activeMs * MC_PER_BROWSER_MS);
9989
- }
9990
- function concurrencySlotBillingInfo() {
9991
- const billingUrl = process.env.CONCURRENCY_BILLING_URL ?? "https://mcpscraper.dev/billing";
9992
- return {
9993
- product: "Extra concurrency slot",
9994
- price_label: CONCURRENCY_SLOT_PRICE_LABEL,
9995
- unit_amount_usd: CONCURRENCY_SLOT_PRICE_USD,
9996
- currency: CONCURRENCY_SLOT_PRICE_CURRENCY,
9997
- interval: CONCURRENCY_SLOT_PRICE_INTERVAL,
9998
- billing_url: billingUrl,
9999
- terminal_command: CONCURRENCY_SLOT_TERMINAL_COMMAND,
10000
- terminal_command_with_api_key_env: `MCP_SCRAPER_API_KEY=sk_live_your_key ${CONCURRENCY_SLOT_TERMINAL_COMMAND}`
10001
- };
10002
- }
10003
- function mcToCredits(mc) {
10004
- return mc / MC_PER_CREDIT;
10005
- }
10006
- function insufficientBalanceResponse(balanceMc, requiredMc) {
10007
- const topupUrl = process.env.TOPUP_URL ?? "https://mcpscraper.dev/billing";
10008
- const balanceCredits = mcToCredits(balanceMc);
10009
- const requiredCredits = mcToCredits(requiredMc);
10010
- return {
10011
- error: "insufficient_balance",
10012
- error_code: "insufficient_balance",
10013
- message: `Insufficient credits. Balance: ${balanceCredits} credits. This call requires ${requiredCredits} credits. Top up at ${topupUrl}`,
10014
- balance_credits: balanceCredits,
10015
- required_credits: requiredCredits,
10016
- topup_url: topupUrl
10017
- };
10018
- }
10019
- var MC_COSTS, MC_PER_BROWSER_MS, BROWSER_OPEN_MIN_BALANCE_MC, MC_PER_CREDIT, CREDIT_COST_CATALOG, CONCURRENCY_PRICE_ID, SUBSCRIPTION_TIERS, SUBSCRIPTION_TIER_BY_KEY, CONCURRENCY_SLOT_PRICE_USD, CONCURRENCY_SLOT_PRICE_CURRENCY, CONCURRENCY_SLOT_PRICE_INTERVAL, CONCURRENCY_SLOT_PRICE_LABEL, CONCURRENCY_SLOT_TERMINAL_COMMAND, FREE_SIGNUP_MC, MEMORY_FREE_QUOTA_BYTES, MEMORY_PLANS, MEMORY_PLAN_QUOTA, MEMORY_MARGIN_MULTIPLE, MEMORY_PRO_COST_BUDGET_USD, SCHEDULING_PLANS, LedgerOperation, MEMORY_AI_MARGIN_MULTIPLE, MC_PER_USD;
10020
- var init_rates = __esm({
10021
- "src/api/rates.ts"() {
10022
- "use strict";
10023
- MC_COSTS = {
10024
- serp: 100,
10025
- paa: 200,
10026
- paa_base: 1e3,
10027
- page_scrape: 100,
10028
- url_map: 500,
10029
- yt_channel: 200,
10030
- yt_transcription: 200,
10031
- fb_ad: 200,
10032
- maps_search: 500,
10033
- maps_place: 3e3,
10034
- maps_review: 100,
10035
- fb_search: 600,
10036
- fb_transcribe: 200,
10037
- google_ads_search: 600,
10038
- google_ads_intel: 200,
10039
- google_ads_transcribe: 200,
10040
- instagram_profile: 400,
10041
- instagram_media: 400,
10042
- instagram_transcribe: 50,
10043
- browser_minute: 12e3,
10044
- reddit_thread: 2e3,
10045
- video_analysis: 666667,
10046
- trustpilot_reviews: 500,
10047
- trustpilot_review: 100,
10048
- g2_reviews: 500,
10049
- g2_review: 150
10050
- };
10051
- MC_PER_BROWSER_MS = MC_COSTS.browser_minute / 6e4;
10052
- BROWSER_OPEN_MIN_BALANCE_MC = 1e3;
10053
- MC_PER_CREDIT = 1e3;
10054
- CREDIT_COST_CATALOG = [
10055
- {
10056
- key: "serp",
10057
- label: "SERP search",
10058
- aliases: ["search_serp", "serp", "google search", "organic results"],
10059
- credits: mcToCredits(MC_COSTS.serp),
10060
- unit: "per search",
10061
- notes: "Returns AI Overview, PAA snippet, videos, forums, and local pack."
10062
- },
10063
- {
10064
- key: "paa",
10065
- label: "PAA harvest",
10066
- aliases: ["harvest_paa", "paa", "people also ask", "questions"],
10067
- credits: mcToCredits(MC_COSTS.paa),
10068
- unit: `per question (+${mcToCredits(MC_COSTS.paa_base)} credit base)`,
10069
- notes: `Full SERP feature extraction. Billed ${mcToCredits(MC_COSTS.paa_base)} credit base + ${mcToCredits(MC_COSTS.paa)} per question actually returned (unused estimate refunded).`
10070
- },
10071
- {
10072
- key: "page_scrape",
10073
- label: "Page crawl / extract",
10074
- aliases: ["extract_url", "extract_site", "page scrape", "url scrape", "single page", "site crawl"],
10075
- credits: mcToCredits(MC_COSTS.page_scrape),
10076
- unit: "per page",
10077
- notes: "Applies to both single-URL extraction and per-page site crawls."
10078
- },
10079
- {
10080
- key: "url_map",
10081
- label: "Site URL mapping",
10082
- aliases: ["map_site_urls", "url map", "site map", "crawl urls"],
10083
- credits: mcToCredits(MC_COSTS.url_map),
10084
- unit: "per mapping operation",
10085
- notes: "Flat rate for the full /map-urls call regardless of URL count discovered."
10086
- },
10087
- {
10088
- key: "yt_channel",
10089
- label: "YouTube search / channel harvest",
10090
- aliases: ["youtube_harvest", "youtube search", "youtube channel", "yt_channel"],
10091
- credits: mcToCredits(MC_COSTS.yt_channel),
10092
- unit: "per call"
10093
- },
10094
- {
10095
- key: "yt_transcription",
10096
- label: "YouTube transcription",
10097
- aliases: ["youtube_transcribe", "youtube transcript", "transcription", "yt_transcription"],
10098
- credits: mcToCredits(MC_COSTS.yt_transcription),
10099
- unit: "per minute",
10100
- notes: "A 5-minute hold is taken, then reconciled to actual video duration."
10101
- },
10102
- {
10103
- key: "fb_ad",
10104
- label: "Facebook search / ad lookup",
10105
- aliases: ["facebook_page_intel", "facebook_ad_search", "facebook_ad", "facebook ads", "fb ads"],
10106
- credits: mcToCredits(MC_COSTS.fb_ad),
10107
- unit: "per call"
10108
- },
10109
- {
10110
- key: "maps_search",
10111
- label: "Maps business search",
10112
- aliases: ["maps_search", "google maps search", "gmb search", "gbp search", "business profiles"],
10113
- credits: mcToCredits(MC_COSTS.maps_search),
10114
- unit: "per search",
10115
- notes: "Returns up to 50 Google Maps business/profile candidates. Use maps_place_intel to hydrate selected businesses."
10116
- },
10117
- {
10118
- key: "maps_place",
10119
- label: "Maps business lookup",
10120
- aliases: ["maps_place_intel", "google maps", "maps place", "place intel"],
10121
- credits: mcToCredits(MC_COSTS.maps_place),
10122
- unit: "per business",
10123
- notes: "Base lookup. Reviews billed separately per card at maps_review rate."
10124
- },
10125
- {
10126
- key: "maps_review",
10127
- label: "Maps review",
10128
- aliases: ["maps_reviews", "google reviews", "review cards", "reviews"],
10129
- credits: mcToCredits(MC_COSTS.maps_review),
10130
- unit: "per review card",
10131
- notes: "Charged after extraction when includeReviews is true."
10132
- },
10133
- {
10134
- key: "fb_search",
10135
- label: "Facebook ad library search",
10136
- aliases: ["facebook_search", "fb_search", "fb ad search"],
10137
- credits: mcToCredits(MC_COSTS.fb_search),
10138
- unit: "per search",
10139
- notes: "Browser automation to search Facebook Ads Library by keyword."
10140
- },
10141
- {
10142
- key: "google_ads_search",
10143
- label: "Google Ads Transparency search",
10144
- aliases: ["google_ads_search", "google ads search", "ads transparency search"],
10145
- credits: mcToCredits(MC_COSTS.google_ads_search),
10146
- unit: "per search",
10147
- notes: "Browser automation to find advertisers in Google Ads Transparency Center by domain or name."
10148
- },
10149
- {
10150
- key: "google_ads_intel",
10151
- label: "Google Ads Transparency advertiser intel",
10152
- aliases: ["google_ads_page_intel", "google ads intel", "ads transparency intel"],
10153
- credits: mcToCredits(MC_COSTS.google_ads_intel),
10154
- unit: "per call",
10155
- notes: "Lists and hydrates an advertiser's creatives with image URLs and video references."
10156
- },
10157
- {
10158
- key: "google_ads_transcribe",
10159
- label: "Google ad video transcription",
10160
- aliases: ["google_ads_transcribe", "google ad transcript"],
10161
- credits: mcToCredits(MC_COSTS.google_ads_transcribe),
10162
- unit: "per minute",
10163
- notes: "A hold is taken, then reconciled to actual video duration."
10164
- },
10165
- {
10166
- key: "fb_transcribe",
10167
- label: "Facebook video / ad transcription",
10168
- aliases: ["facebook_transcribe", "facebook_video_transcribe", "fb_transcribe", "fb ad transcript"],
10169
- credits: mcToCredits(MC_COSTS.fb_transcribe),
10170
- unit: "per minute",
10171
- notes: "A hold is taken, then reconciled to actual video duration."
10172
- },
10173
- {
10174
- key: "instagram_profile",
10175
- label: "Instagram profile content discovery",
10176
- aliases: ["instagram_profile_content", "instagram profile", "instagram content list", "ig profile"],
10177
- credits: mcToCredits(MC_COSTS.instagram_profile),
10178
- unit: "per profile scan",
10179
- notes: "Browser extraction of public Instagram profile grid links. Complete history may require a logged-in profile."
10180
- },
10181
- {
10182
- key: "instagram_media",
10183
- label: "Instagram media download",
10184
- aliases: ["instagram_media_download", "instagram reel download", "instagram post download", "ig media"],
10185
- credits: mcToCredits(MC_COSTS.instagram_media),
10186
- unit: "per post or reel",
10187
- notes: "Extracts post text, image URLs, and reel audio/video tracks, with local downloads when the server can write files."
10188
- },
10189
- {
10190
- key: "instagram_transcribe",
10191
- label: "Instagram media transcription",
10192
- aliases: ["instagram transcript", "instagram reel transcript", "ig transcribe"],
10193
- credits: mcToCredits(MC_COSTS.instagram_transcribe),
10194
- unit: "per call",
10195
- notes: "Whisper transcription of selected Instagram audio/video media via fal.ai."
10196
- },
10197
- {
10198
- key: "browser_minute",
10199
- label: "Interactive browser session",
10200
- aliases: ["browser_open", "browser agent", "browser_agent", "live browser", "browse", "browser control", "interactive browser"],
10201
- credits: mcToCredits(MC_COSTS.browser_minute),
10202
- unit: "per minute of use",
10203
- notes: "Metered per minute of use for the whole time the browser session is open. Close the session to stop the meter; abandoned sessions are auto-closed after a short idle window."
10204
- },
10205
- {
10206
- key: "reddit_thread",
10207
- label: "Reddit thread + comments",
10208
- aliases: ["reddit", "reddit_thread", "reddit comments", "subreddit", "reddit post"],
10209
- credits: mcToCredits(MC_COSTS.reddit_thread),
10210
- unit: "per thread",
10211
- notes: "Captures a Reddit post and its comment tree. Charged on success; refunded if the thread cannot be retrieved."
10212
- },
10213
- {
10214
- key: "video_analysis",
10215
- label: "Video breakdown (frame-by-frame + transcript)",
10216
- aliases: ["video_frame_analysis", "video breakdown", "video analysis", "analyze video"],
10217
- credits: mcToCredits(MC_COSTS.video_analysis),
10218
- unit: "per 120 frames (max 480)",
10219
- notes: "Full multi-lens video breakdown: samples frames across a video (up to 30 minutes), analyzes each with vision AI, transcribes the audio, then produces summary, pacing/energy, words-per-minute, topic outline, key points, hook analysis, visual style, and a how-to-replicate recipe with a quality-control pass. $1 per 120 frames requested (max 480 = $4); billed down automatically if the video cannot use the requested frames, and refunded fully if the run fails."
10220
- },
10221
- {
10222
- key: "trustpilot_reviews",
10223
- label: "Trustpilot review harvest",
10224
- aliases: ["trustpilot_reviews", "trustpilot", "trustpilot reviews"],
10225
- credits: mcToCredits(MC_COSTS.trustpilot_reviews),
10226
- unit: "per call",
10227
- notes: "Base lookup. Reviews billed separately per card at trustpilot_review rate. Refunded if no reviews are found."
10228
- },
10229
- {
10230
- key: "trustpilot_review",
10231
- label: "Trustpilot review card",
10232
- aliases: ["trustpilot_review", "trustpilot card"],
10233
- credits: mcToCredits(MC_COSTS.trustpilot_review),
10234
- unit: "per review card",
10235
- notes: "Charged per review actually extracted, billed down automatically if the balance runs out mid-page."
10236
- },
10237
- {
10238
- key: "g2_reviews",
10239
- label: "G2 review harvest",
10240
- aliases: ["g2_reviews", "g2", "g2 reviews"],
10241
- credits: mcToCredits(MC_COSTS.g2_reviews),
10242
- unit: "per call",
10243
- notes: "Base lookup. Reviews billed separately per card at g2_review rate. Refunded if no reviews are found."
10244
- },
10245
- {
10246
- key: "g2_review",
10247
- label: "G2 review card",
10248
- aliases: ["g2_review", "g2 card"],
10249
- credits: mcToCredits(MC_COSTS.g2_review),
10250
- unit: "per review card",
10251
- notes: "Charged per review actually extracted (each card carries up to 3 Q&A sections), billed down automatically if the balance runs out mid-page."
10252
- }
10253
- ];
10254
- CONCURRENCY_PRICE_ID = "price_1Ta1NRS8aAcsk3TGwsRnYbix";
10255
- SUBSCRIPTION_TIERS = {
10256
- "price_1TmiHRS8aAcsk3TGwmSNfNIa": { tier: "starter", label: "Starter", price_id: "price_1TmiHRS8aAcsk3TGwmSNfNIa", monthly_usd: 12, credits_mc: 8e6, concurrency: 3, intro_coupon: "mcp-starter-1dollar-intro-12" },
10257
- "price_1Tmg1nS8aAcsk3TGe8zcnGTM": { tier: "growth", label: "Growth", price_id: "price_1Tmg1nS8aAcsk3TGe8zcnGTM", monthly_usd: 100, credits_mc: 8e7, concurrency: 10, intro_coupon: null },
10258
- "price_1Tmg1nS8aAcsk3TGsZw34iXS": { tier: "scale", label: "Scale", price_id: "price_1Tmg1nS8aAcsk3TGsZw34iXS", monthly_usd: 250, credits_mc: 24e7, concurrency: 20, intro_coupon: null }
10259
- };
10260
- SUBSCRIPTION_TIER_BY_KEY = Object.fromEntries(
10261
- Object.values(SUBSCRIPTION_TIERS).map((t) => [t.tier, t])
10262
- );
10263
- CONCURRENCY_SLOT_PRICE_USD = 5;
10264
- CONCURRENCY_SLOT_PRICE_CURRENCY = "usd";
10265
- CONCURRENCY_SLOT_PRICE_INTERVAL = "month";
10266
- CONCURRENCY_SLOT_PRICE_LABEL = "$5/month";
10267
- CONCURRENCY_SLOT_TERMINAL_COMMAND = "npx -y -p mcp-scraper@latest mcp-scraper-cli billing concurrency checkout";
10268
- FREE_SIGNUP_MC = 1e5;
10269
- MEMORY_FREE_QUOTA_BYTES = 1e7 * 1;
10270
- MEMORY_PLANS = {
10271
- "price_1TnMSTS8aAcsk3TGBgiwuvqL": { plan: "pro", label: "Pro", price_id: "price_1TnMSTS8aAcsk3TGBgiwuvqL", interval: "month", monthly_usd: 19, quota_bytes: 5e9 },
10272
- "price_1TnMSTS8aAcsk3TGWBVU2agY": { plan: "pro", label: "Pro", price_id: "price_1TnMSTS8aAcsk3TGWBVU2agY", interval: "year", monthly_usd: 19, quota_bytes: 5e9 }
10273
- };
10274
- MEMORY_PLAN_QUOTA = { free: MEMORY_FREE_QUOTA_BYTES, pro: 5e9, team: 5e10 };
10275
- MEMORY_MARGIN_MULTIPLE = 3;
10276
- MEMORY_PRO_COST_BUDGET_USD = 19 / MEMORY_MARGIN_MULTIPLE;
10277
- SCHEDULING_PLANS = {
10278
- "price_1ToXcHS8aAcsk3TGf3cW7zHx": { price_id: "price_1ToXcHS8aAcsk3TGf3cW7zHx", label: "Scheduled Actions", interval: "month", monthly_usd: 10, quota_per_period: 1e3 }
10279
- };
10280
- LedgerOperation = {
10281
- TOPUP: "topup",
10282
- SUBSCRIPTION: "subscription",
10283
- SIGNUP_GRANT: "signup_grant",
10284
- MONTHLY_REFRESH: "monthly_free_refresh",
10285
- PAA: "paa",
10286
- PAA_REFUND: "paa_refund",
10287
- SERP: "serp",
10288
- REFUND: "refund",
10289
- TRANSCRIPTION: "transcription",
10290
- TRANSCRIPTION_HOLD: "transcription_hold",
10291
- TRANSCRIPTION_REFUND: "transcription_refund",
10292
- YT_CHANNEL: "yt_channel",
10293
- FB_AD: "fb_ad",
10294
- MAPS_SEARCH: "maps_search",
10295
- MAPS_PLACE: "maps_place",
10296
- MAPS_REVIEW: "maps_review",
10297
- MAPS_REVIEW_REFUND: "maps_review_refund",
10298
- EXTRACT_SITE: "extract_site",
10299
- EXTRACT_SITE_REFUND: "extract_site_refund",
10300
- EXTRACT_URL: "page_scrape",
10301
- URL_MAP: "url_map",
10302
- EXTRACT_SITE_HOLD: "extract_site_hold",
10303
- YT_CHANNEL_REFUND: "yt_channel_refund",
10304
- FB_AD_REFUND: "fb_ad_refund",
10305
- URL_MAP_REFUND: "url_map_refund",
10306
- FB_SEARCH: "fb_search",
10307
- FB_TRANSCRIBE: "fb_transcribe",
10308
- FB_SEARCH_REFUND: "fb_search_refund",
10309
- FB_TRANSCRIBE_REFUND: "fb_transcribe_refund",
10310
- GOOGLE_ADS_SEARCH: "google_ads_search",
10311
- GOOGLE_ADS_SEARCH_REFUND: "google_ads_search_refund",
10312
- GOOGLE_ADS_INTEL: "google_ads_intel",
10313
- GOOGLE_ADS_INTEL_REFUND: "google_ads_intel_refund",
10314
- GOOGLE_ADS_TRANSCRIBE: "google_ads_transcribe",
10315
- GOOGLE_ADS_TRANSCRIBE_REFUND: "google_ads_transcribe_refund",
10316
- INSTAGRAM_PROFILE: "instagram_profile",
10317
- INSTAGRAM_PROFILE_REFUND: "instagram_profile_refund",
10318
- INSTAGRAM_MEDIA: "instagram_media",
10319
- INSTAGRAM_MEDIA_REFUND: "instagram_media_refund",
10320
- INSTAGRAM_TRANSCRIBE: "instagram_transcribe",
10321
- INSTAGRAM_TRANSCRIBE_REFUND: "instagram_transcribe_refund",
10322
- BROWSER_SESSION: "browser_session",
10323
- REDDIT_THREAD: "reddit_thread",
10324
- REDDIT_THREAD_REFUND: "reddit_thread_refund",
10325
- VIDEO_ANALYSIS: "video_analysis",
10326
- VIDEO_ANALYSIS_REFUND: "video_analysis_refund",
10327
- VIDEO_ANALYSIS_ADJUST: "video_analysis_adjust",
10328
- MEMORY_AI: "memory_ai",
10329
- MEMORY_AI_REFUND: "memory_ai_refund",
10330
- TRUSTPILOT_REVIEWS: "trustpilot_reviews",
10331
- TRUSTPILOT_REVIEWS_REFUND: "trustpilot_reviews_refund",
10332
- TRUSTPILOT_REVIEW: "trustpilot_review",
10333
- TRUSTPILOT_REVIEW_REFUND: "trustpilot_review_refund",
10334
- G2_REVIEWS: "g2_reviews",
10335
- G2_REVIEWS_REFUND: "g2_reviews_refund",
10336
- G2_REVIEW: "g2_review",
10337
- G2_REVIEW_REFUND: "g2_review_refund"
10338
- };
10339
- MEMORY_AI_MARGIN_MULTIPLE = 3;
10340
- MC_PER_USD = SUBSCRIPTION_TIERS["price_1TmiHRS8aAcsk3TGwmSNfNIa"].credits_mc / SUBSCRIPTION_TIERS["price_1TmiHRS8aAcsk3TGwmSNfNIa"].monthly_usd;
10341
- }
10342
- });
10343
-
10344
10483
  // src/api/site-extract-repository.ts
10345
10484
  var site_extract_repository_exports = {};
10346
10485
  __export(site_extract_repository_exports, {
@@ -11560,6 +11699,8 @@ var init_internal_memory_routes = __esm({
11560
11699
  import_hono2 = require("hono");
11561
11700
  init_db();
11562
11701
  init_rates();
11702
+ init_cost_telemetry();
11703
+ init_cost_context();
11563
11704
  internalMemoryApp = new import_hono2.Hono();
11564
11705
  internalMemoryApp.post("/ai-debit", async (c) => {
11565
11706
  const secret2 = c.req.header("x-internal-secret");
@@ -11572,6 +11713,11 @@ var init_internal_memory_routes = __esm({
11572
11713
  }
11573
11714
  const user = await getUserByEmail(body.identity.trim().toLowerCase());
11574
11715
  if (!user) return c.json({ ok: false, reason: "no_user" }, 200);
11716
+ if (body.costUsd > 0) {
11717
+ await runWithCostContext({ ...currentCostContext(), op: "memory_ai", userId: user.id }, async () => {
11718
+ void recordVendorUsage({ vendor: "mcp_memory_ai", model: null, units: body.costUsd, unitType: "usd", error: null });
11719
+ });
11720
+ }
11575
11721
  const mc = Math.round(body.costUsd * MEMORY_AI_MARGIN_MULTIPLE * MC_PER_USD);
11576
11722
  if (mc <= 0) return c.json({ ok: true, mc: 0, balance_mc: null }, 200);
11577
11723
  const { ok, balance_mc } = await debitMc(user.id, mc, LedgerOperation.MEMORY_AI, `${body.source ?? "ai"}:${body.op ?? ""}`);
@@ -12834,6 +12980,563 @@ var init_youtube_harvest = __esm({
12834
12980
  }
12835
12981
  });
12836
12982
 
12983
+ // src/locations.ts
12984
+ var LOCATIONS;
12985
+ var init_locations = __esm({
12986
+ "src/locations.ts"() {
12987
+ "use strict";
12988
+ LOCATIONS = {
12989
+ "austin": "Austin,Texas,United States",
12990
+ "new york": "New York,New York,United States",
12991
+ "new york city": "New York,New York,United States",
12992
+ "nyc": "New York,New York,United States",
12993
+ "los angeles": "Los Angeles,California,United States",
12994
+ "la": "Los Angeles,California,United States",
12995
+ "chicago": "Chicago,Illinois,United States",
12996
+ "houston": "Houston,Texas,United States",
12997
+ "phoenix": "Phoenix,Arizona,United States",
12998
+ "philadelphia": "Philadelphia,Pennsylvania,United States",
12999
+ "philly": "Philadelphia,Pennsylvania,United States",
13000
+ "san antonio": "San Antonio,Texas,United States",
13001
+ "dallas": "Dallas,Texas,United States",
13002
+ "miami": "Miami,Florida,United States",
13003
+ "seattle": "Seattle,Washington,United States",
13004
+ "denver": "Denver,Colorado,United States",
13005
+ "loveland": "Loveland,Colorado,United States",
13006
+ "loveland co": "Loveland,Colorado,United States",
13007
+ "fort collins": "Fort Collins,Colorado,United States",
13008
+ "boulder": "Boulder,Colorado,United States",
13009
+ "colorado springs": "Colorado Springs,Colorado,United States",
13010
+ "boston": "Boston,Massachusetts,United States",
13011
+ "atlanta": "Atlanta,Georgia,United States",
13012
+ "san francisco": "San Francisco,California,United States",
13013
+ "sf": "San Francisco,California,United States",
13014
+ "portland": "Portland,Oregon,United States",
13015
+ "las vegas": "Las Vegas,Nevada,United States",
13016
+ "minneapolis": "Minneapolis,Minnesota,United States",
13017
+ "detroit": "Detroit,Michigan,United States",
13018
+ "nashville": "Nashville,Tennessee,United States",
13019
+ "charlotte": "Charlotte,North Carolina,United States",
13020
+ "orlando": "Orlando,Florida,United States",
13021
+ "san diego": "San Diego,California,United States",
13022
+ "baltimore": "Baltimore,Maryland,United States",
13023
+ "sacramento": "Sacramento,California,United States",
13024
+ "columbus": "Columbus,Ohio,United States",
13025
+ "indianapolis": "Indianapolis,Indiana,United States",
13026
+ "san jose": "San Jose,California,United States",
13027
+ "fort worth": "Fort Worth,Texas,United States",
13028
+ "jacksonville": "Jacksonville,Florida,United States",
13029
+ "memphis": "Memphis,Tennessee,United States",
13030
+ "louisville": "Louisville,Kentucky,United States",
13031
+ "raleigh": "Raleigh,North Carolina,United States",
13032
+ "richmond": "Richmond,Virginia,United States",
13033
+ "salt lake city": "Salt Lake City,Utah,United States",
13034
+ "toronto": "Toronto,Ontario,Canada",
13035
+ "vancouver": "Vancouver,British Columbia,Canada",
13036
+ "montreal": "Montreal,Quebec,Canada",
13037
+ "calgary": "Calgary,Alberta,Canada",
13038
+ "ottawa": "Ottawa,Ontario,Canada",
13039
+ "london": "London,England,United Kingdom",
13040
+ "manchester": "Manchester,England,United Kingdom",
13041
+ "birmingham": "Birmingham,England,United Kingdom",
13042
+ "edinburgh": "Edinburgh,Scotland,United Kingdom",
13043
+ "glasgow": "Glasgow,Scotland,United Kingdom",
13044
+ "leeds": "Leeds,England,United Kingdom",
13045
+ "sydney": "Sydney,New South Wales,Australia",
13046
+ "melbourne": "Melbourne,Victoria,Australia",
13047
+ "brisbane": "Brisbane,Queensland,Australia",
13048
+ "perth": "Perth,Western Australia,Australia",
13049
+ "adelaide": "Adelaide,South Australia,Australia",
13050
+ "dublin": "Dublin,Leinster,Ireland"
13051
+ };
13052
+ }
13053
+ });
13054
+
13055
+ // src/uule.ts
13056
+ function encodeVarint(value) {
13057
+ const bytes = [];
13058
+ let remaining = value;
13059
+ do {
13060
+ let byte = remaining & 127;
13061
+ remaining >>>= 7;
13062
+ if (remaining > 0) byte |= 128;
13063
+ bytes.push(byte);
13064
+ } while (remaining > 0);
13065
+ return bytes;
13066
+ }
13067
+ function encodeUule(name) {
13068
+ const locationBytes = Buffer.from(name, "utf8");
13069
+ const payload = Buffer.concat([
13070
+ Buffer.from([8, 2, 16, 32, 34]),
13071
+ Buffer.from(encodeVarint(locationBytes.length)),
13072
+ locationBytes
13073
+ ]);
13074
+ return `w+${payload.toString("base64")}`;
13075
+ }
13076
+ function normalizeLocation(input) {
13077
+ const raw = input.toLowerCase().trim();
13078
+ if (LOCATIONS[raw]) return LOCATIONS[raw];
13079
+ const beforeComma = raw.split(",")[0].trim();
13080
+ if (beforeComma !== raw && LOCATIONS[beforeComma]) return LOCATIONS[beforeComma];
13081
+ const withoutState = raw.replace(/\s+[a-z]{2}$/, "").trim();
13082
+ if (withoutState !== raw && LOCATIONS[withoutState]) return LOCATIONS[withoutState];
13083
+ return input;
13084
+ }
13085
+ var init_uule = __esm({
13086
+ "src/uule.ts"() {
13087
+ "use strict";
13088
+ init_locations();
13089
+ }
13090
+ });
13091
+
13092
+ // src/kernel-proxy-resolver.ts
13093
+ function proxyIdSuffix2(proxyId) {
13094
+ return proxyId ? proxyId.slice(-6) : null;
13095
+ }
13096
+ function resolution(source, proxyMode, proxyId, target, error, disposable = false) {
13097
+ const proxyType = source === "disabled" ? "none" : source === "configured_fallback" ? "configured" : source === "isp_created" ? "isp" : source === "location_created" || source === "location_reused" ? "residential" : "unknown";
13098
+ return {
13099
+ kernelProxyId: proxyId,
13100
+ ...disposable && proxyId ? { disposableProxyId: proxyId } : {},
13101
+ resolution: {
13102
+ source,
13103
+ proxyType,
13104
+ proxyMode,
13105
+ proxyIdPresent: Boolean(proxyId),
13106
+ proxyIdSuffix: proxyIdSuffix2(proxyId),
13107
+ target,
13108
+ error,
13109
+ disposable
13110
+ }
13111
+ };
13112
+ }
13113
+ function normalizeStateName(value) {
13114
+ return value.trim().toLowerCase().replace(/\s+/g, " ");
13115
+ }
13116
+ function normalizeCountryName(value) {
13117
+ return value.trim().toLowerCase().replace(/\./g, "").replace(/\s+/g, " ");
13118
+ }
13119
+ function isUnitedStates(country) {
13120
+ if (!country) return true;
13121
+ const normalized = normalizeCountryName(country);
13122
+ return normalized === "united states" || normalized === "united states of america" || normalized === "usa" || normalized === "us";
13123
+ }
13124
+ function stateCodeFor(region) {
13125
+ const trimmed = region.trim();
13126
+ if (/^[A-Za-z]{2}$/.test(trimmed)) return trimmed.toUpperCase();
13127
+ return US_STATE_CODES[normalizeStateName(trimmed)] ?? null;
13128
+ }
13129
+ function kernelCityIdentifierCandidates(city) {
13130
+ const ascii = city.normalize("NFKD").replace(/[^\x00-\x7F]/g, "").toLowerCase();
13131
+ const words = ascii.split(/[^a-z0-9]+/).filter(Boolean);
13132
+ const underscored = words.join("_");
13133
+ const compact = words.join("");
13134
+ return Array.from(new Set([underscored, compact].filter(Boolean)));
13135
+ }
13136
+ function proxyName(country, state, city) {
13137
+ return city ? `mcp-serp-residential-${country.toLowerCase()}-${state.toLowerCase()}-${city}` : `mcp-serp-residential-${country.toLowerCase()}-${state.toLowerCase()}`;
13138
+ }
13139
+ function freshProxyName(baseName, attemptIndex) {
13140
+ const stamp = `${Date.now()}-${attemptIndex ?? 0}-${Math.random().toString(36).slice(2, 8)}`;
13141
+ return `${baseName}-fresh-${stamp}`;
13142
+ }
13143
+ function zipProxyName(zip) {
13144
+ return `mcp-serp-residential-us-zip-${zip}`;
13145
+ }
13146
+ function parseKernelLocationProxyTarget(location2, gl) {
13147
+ if (!location2 || gl.toLowerCase() !== "us") return null;
13148
+ const canonicalLocation = normalizeLocation(location2);
13149
+ let parts = canonicalLocation.split(",").map((part) => part.trim()).filter(Boolean);
13150
+ if (parts.length > 1 && isUnitedStates(parts[parts.length - 1])) {
13151
+ parts = parts.slice(0, -1);
13152
+ }
13153
+ if (parts.length === 1) {
13154
+ const stateOnly = stateCodeFor(parts[0]);
13155
+ if (!stateOnly) return null;
13156
+ return {
13157
+ canonicalLocation,
13158
+ level: "state",
13159
+ country: "US",
13160
+ state: stateOnly,
13161
+ city: "",
13162
+ cityCandidates: [],
13163
+ proxyName: proxyName("US", stateOnly),
13164
+ config: {
13165
+ country: "US",
13166
+ state: stateOnly
13167
+ }
13168
+ };
13169
+ }
13170
+ const [city = "", region = ""] = parts;
13171
+ if (!city || !region) return null;
13172
+ const state = stateCodeFor(region);
13173
+ if (!state) return null;
13174
+ const cityCandidates = kernelCityIdentifierCandidates(city);
13175
+ const primaryCity = cityCandidates[0];
13176
+ if (!primaryCity) return null;
13177
+ return {
13178
+ canonicalLocation,
13179
+ level: "city",
13180
+ country: "US",
13181
+ state,
13182
+ city: primaryCity,
13183
+ cityCandidates,
13184
+ proxyName: proxyName("US", state, primaryCity),
13185
+ config: {
13186
+ country: "US",
13187
+ state,
13188
+ city: primaryCity
13189
+ }
13190
+ };
13191
+ }
13192
+ function cityZipKey(target) {
13193
+ return `${target.city}|${target.state}`;
13194
+ }
13195
+ function knownZipFor(target, explicitZip) {
13196
+ if (explicitZip && /^\d{5}$/.test(explicitZip)) return explicitZip;
13197
+ return US_CITY_CENTER_ZIPS[cityZipKey(target)] ?? null;
13198
+ }
13199
+ function zipTarget(target, zip) {
13200
+ return {
13201
+ ...target,
13202
+ level: "zip",
13203
+ zip,
13204
+ proxyName: zipProxyName(zip),
13205
+ config: {
13206
+ country: target.country,
13207
+ state: target.state,
13208
+ zip
13209
+ }
13210
+ };
13211
+ }
13212
+ function withProxyName(target, name) {
13213
+ return {
13214
+ ...target,
13215
+ proxyName: name
13216
+ };
13217
+ }
13218
+ function configMatches(config, target, city) {
13219
+ if (target.level === "zip") {
13220
+ return config?.country?.toUpperCase() === target.country && config?.zip === target.zip;
13221
+ }
13222
+ return config?.country?.toUpperCase() === target.country && config?.state?.toUpperCase() === target.state && (city ? config?.city === city : !config?.city);
13223
+ }
13224
+ function findExistingTargetProxy(proxies, target) {
13225
+ return proxies.find((proxy) => proxy.type === "residential" && proxy.status !== "unavailable" && Boolean(proxy.id) && (proxy.name === target.proxyName || configMatches(proxy.config, target, target.level === "city" ? target.city : void 0))) ?? null;
13226
+ }
13227
+ function findExistingProxy(proxies, target) {
13228
+ for (const city of target.cityCandidates) {
13229
+ const name = proxyName(target.country, target.state, city);
13230
+ const found = proxies.find((proxy) => proxy.type === "residential" && proxy.status !== "unavailable" && Boolean(proxy.id) && (proxy.name === name || configMatches(proxy.config, target, city)));
13231
+ if (found) return found;
13232
+ }
13233
+ return null;
13234
+ }
13235
+ function stateTarget(target) {
13236
+ return {
13237
+ ...target,
13238
+ level: "state",
13239
+ proxyName: proxyName(target.country, target.state),
13240
+ config: {
13241
+ country: target.country,
13242
+ state: target.state
13243
+ }
13244
+ };
13245
+ }
13246
+ function findExistingStateProxy(proxies, target) {
13247
+ const name = proxyName(target.country, target.state);
13248
+ return proxies.find((proxy) => proxy.type === "residential" && proxy.status !== "unavailable" && Boolean(proxy.id) && (proxy.name === name || configMatches(proxy.config, target))) ?? null;
13249
+ }
13250
+ function escalatedTargetLevel(target, attemptIndex) {
13251
+ return stateTarget(target);
13252
+ }
13253
+ function errorText2(err) {
13254
+ return err instanceof Error ? err.message : String(err);
13255
+ }
13256
+ function freshTargetCandidates(target, explicitZip, attemptIndex) {
13257
+ const out = [];
13258
+ const zip = knownZipFor(target, explicitZip);
13259
+ if (zip) {
13260
+ const targetZip = zipTarget(target, zip);
13261
+ out.push(withProxyName(targetZip, freshProxyName(targetZip.proxyName, attemptIndex)));
13262
+ }
13263
+ for (const city of target.cityCandidates) {
13264
+ const cityTarget = {
13265
+ ...target,
13266
+ level: "city",
13267
+ city,
13268
+ proxyName: proxyName(target.country, target.state, city),
13269
+ config: {
13270
+ country: target.country,
13271
+ state: target.state,
13272
+ city
13273
+ }
13274
+ };
13275
+ out.push(withProxyName(cityTarget, freshProxyName(cityTarget.proxyName, attemptIndex)));
13276
+ }
13277
+ const fallbackTarget = stateTarget(target);
13278
+ out.push(withProxyName(fallbackTarget, freshProxyName(fallbackTarget.proxyName, attemptIndex)));
13279
+ return out;
13280
+ }
13281
+ async function createFreshLocationProxy(kernel, options, target) {
13282
+ const createErrors = [];
13283
+ for (const candidate of freshTargetCandidates(target, options.proxyZip, options.attemptIndex)) {
13284
+ try {
13285
+ const created = await kernel.proxies.create({
13286
+ type: "residential",
13287
+ name: candidate.proxyName,
13288
+ config: candidate.level === "zip" ? { country: candidate.country, zip: candidate.zip } : candidate.config
13289
+ });
13290
+ if (created.id) {
13291
+ return resolution("location_created", options.proxyMode, created.id, candidate, null, true);
13292
+ }
13293
+ createErrors.push(`${candidate.proxyName}: Kernel did not return a proxy id`);
13294
+ } catch (err) {
13295
+ createErrors.push(`${candidate.proxyName}: ${errorText2(err)}`);
13296
+ }
13297
+ }
13298
+ if (process.env.KERNEL_PROXY_TYPE !== "residential") {
13299
+ const ispTarget = stateTarget(target);
13300
+ try {
13301
+ const created = await kernel.proxies.create({
13302
+ type: "isp",
13303
+ name: freshProxyName(`mcp-serp-isp-${ispTarget.country.toLowerCase()}-${(ispTarget.state ?? "").toLowerCase()}`, options.attemptIndex),
13304
+ config: { country: ispTarget.country, ...ispTarget.state ? { state: ispTarget.state } : {} }
13305
+ });
13306
+ if (created.id) {
13307
+ return resolution("isp_created", options.proxyMode, created.id, ispTarget, null, true);
13308
+ }
13309
+ createErrors.push("isp: Kernel did not return a proxy id");
13310
+ } catch (err) {
13311
+ createErrors.push(`isp: ${errorText2(err)}`);
13312
+ }
13313
+ }
13314
+ return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, createErrors.join(" | "));
13315
+ }
13316
+ async function deleteKernelProxyId(kernelApiKey, proxyId) {
13317
+ if (!kernelApiKey || !proxyId) return;
13318
+ const kernel = new import_sdk6.default({ apiKey: kernelApiKey });
13319
+ await kernel.proxies.delete(proxyId);
13320
+ }
13321
+ async function resolveKernelProxyId(options) {
13322
+ if (options.proxyMode === "none") {
13323
+ return resolution("disabled", options.proxyMode, void 0, null, null);
13324
+ }
13325
+ if (options.proxyMode === "configured") {
13326
+ return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, null, null);
13327
+ }
13328
+ const target = parseKernelLocationProxyTarget(options.location, options.gl);
13329
+ if (!target || !options.kernelApiKey) {
13330
+ return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, target ? null : "location could not be normalized to a US city/state proxy target");
13331
+ }
13332
+ const kernel = new import_sdk6.default({ apiKey: options.kernelApiKey });
13333
+ try {
13334
+ const attemptIndex = options.attemptIndex ?? 0;
13335
+ if (options.fresh) {
13336
+ return await createFreshLocationProxy(kernel, options, target);
13337
+ }
13338
+ if (attemptIndex >= 1) {
13339
+ const escalatedTarget = escalatedTargetLevel(target, attemptIndex);
13340
+ const createErrors2 = [];
13341
+ try {
13342
+ const created = await kernel.proxies.create({
13343
+ type: "residential",
13344
+ name: escalatedTarget.proxyName,
13345
+ config: escalatedTarget.config
13346
+ });
13347
+ if (created.id) {
13348
+ return resolution("location_created", options.proxyMode, created.id, escalatedTarget, null);
13349
+ }
13350
+ createErrors2.push(`${escalatedTarget.state}: Kernel did not return a proxy id`);
13351
+ } catch (err) {
13352
+ createErrors2.push(`${escalatedTarget.state}: ${errorText2(err)}`);
13353
+ }
13354
+ return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, escalatedTarget, createErrors2.join(" | "));
13355
+ }
13356
+ const proxies = await kernel.proxies.list();
13357
+ const zip = knownZipFor(target, options.proxyZip);
13358
+ const createErrors = [];
13359
+ if (zip) {
13360
+ const targetZip = zipTarget(target, zip);
13361
+ const existingZip = findExistingTargetProxy(proxies, targetZip);
13362
+ if (existingZip?.id) {
13363
+ return resolution("location_reused", options.proxyMode, existingZip.id, targetZip, null);
13364
+ }
13365
+ try {
13366
+ const created = await kernel.proxies.create({
13367
+ type: "residential",
13368
+ name: targetZip.proxyName,
13369
+ config: {
13370
+ country: targetZip.country,
13371
+ zip
13372
+ }
13373
+ });
13374
+ if (created.id) {
13375
+ return resolution("location_created", options.proxyMode, created.id, targetZip, null);
13376
+ }
13377
+ createErrors.push(`${zip}: Kernel did not return a proxy id`);
13378
+ } catch (err) {
13379
+ createErrors.push(`${zip}: ${errorText2(err)}`);
13380
+ }
13381
+ }
13382
+ const existing = findExistingProxy(proxies, target);
13383
+ if (existing?.id) {
13384
+ return resolution("location_reused", options.proxyMode, existing.id, target, createErrors.join(" | ") || null);
13385
+ }
13386
+ for (const city of target.cityCandidates) {
13387
+ try {
13388
+ const created = await kernel.proxies.create({
13389
+ type: "residential",
13390
+ name: proxyName(target.country, target.state, city),
13391
+ config: {
13392
+ country: target.country,
13393
+ state: target.state,
13394
+ city
13395
+ }
13396
+ });
13397
+ if (created.id) {
13398
+ return resolution("location_created", options.proxyMode, created.id, {
13399
+ ...target,
13400
+ level: "city",
13401
+ city,
13402
+ proxyName: proxyName(target.country, target.state, city),
13403
+ config: {
13404
+ country: target.country,
13405
+ state: target.state,
13406
+ city
13407
+ }
13408
+ }, null);
13409
+ }
13410
+ createErrors.push(`${city}: Kernel did not return a proxy id`);
13411
+ } catch (err) {
13412
+ createErrors.push(`${city}: ${errorText2(err)}`);
13413
+ }
13414
+ }
13415
+ const fallbackTarget = stateTarget(target);
13416
+ const existingState = findExistingStateProxy(proxies, fallbackTarget);
13417
+ if (existingState?.id) {
13418
+ return resolution("location_reused", options.proxyMode, existingState.id, fallbackTarget, createErrors.join(" | "));
13419
+ }
13420
+ try {
13421
+ const created = await kernel.proxies.create({
13422
+ type: "residential",
13423
+ name: fallbackTarget.proxyName,
13424
+ config: fallbackTarget.config
13425
+ });
13426
+ if (created.id) {
13427
+ return resolution("location_created", options.proxyMode, created.id, fallbackTarget, createErrors.join(" | "));
13428
+ }
13429
+ createErrors.push(`${fallbackTarget.state}: Kernel did not return a proxy id`);
13430
+ } catch (err) {
13431
+ createErrors.push(`${fallbackTarget.state}: ${errorText2(err)}`);
13432
+ }
13433
+ return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, createErrors.join(" | "));
13434
+ } catch (err) {
13435
+ return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, errorText2(err));
13436
+ }
13437
+ }
13438
+ var import_sdk6, US_STATE_CODES, US_CITY_CENTER_ZIPS;
13439
+ var init_kernel_proxy_resolver = __esm({
13440
+ "src/kernel-proxy-resolver.ts"() {
13441
+ "use strict";
13442
+ import_sdk6 = __toESM(require("@onkernel/sdk"), 1);
13443
+ init_uule();
13444
+ US_STATE_CODES = {
13445
+ alabama: "AL",
13446
+ alaska: "AK",
13447
+ arizona: "AZ",
13448
+ arkansas: "AR",
13449
+ california: "CA",
13450
+ colorado: "CO",
13451
+ connecticut: "CT",
13452
+ delaware: "DE",
13453
+ florida: "FL",
13454
+ georgia: "GA",
13455
+ hawaii: "HI",
13456
+ idaho: "ID",
13457
+ illinois: "IL",
13458
+ indiana: "IN",
13459
+ iowa: "IA",
13460
+ kansas: "KS",
13461
+ kentucky: "KY",
13462
+ louisiana: "LA",
13463
+ maine: "ME",
13464
+ maryland: "MD",
13465
+ massachusetts: "MA",
13466
+ michigan: "MI",
13467
+ minnesota: "MN",
13468
+ mississippi: "MS",
13469
+ missouri: "MO",
13470
+ montana: "MT",
13471
+ nebraska: "NE",
13472
+ nevada: "NV",
13473
+ "new hampshire": "NH",
13474
+ "new jersey": "NJ",
13475
+ "new mexico": "NM",
13476
+ "new york": "NY",
13477
+ "north carolina": "NC",
13478
+ "north dakota": "ND",
13479
+ ohio: "OH",
13480
+ oklahoma: "OK",
13481
+ oregon: "OR",
13482
+ pennsylvania: "PA",
13483
+ "rhode island": "RI",
13484
+ "south carolina": "SC",
13485
+ "south dakota": "SD",
13486
+ tennessee: "TN",
13487
+ texas: "TX",
13488
+ utah: "UT",
13489
+ vermont: "VT",
13490
+ virginia: "VA",
13491
+ washington: "WA",
13492
+ "west virginia": "WV",
13493
+ wisconsin: "WI",
13494
+ wyoming: "WY"
13495
+ };
13496
+ US_CITY_CENTER_ZIPS = {
13497
+ "atlanta|GA": "30303",
13498
+ "austin|TX": "78701",
13499
+ "baltimore|MD": "21201",
13500
+ "boston|MA": "02108",
13501
+ "boulder|CO": "80302",
13502
+ "charlotte|NC": "28202",
13503
+ "chicago|IL": "60601",
13504
+ "colorado_springs|CO": "80903",
13505
+ "columbus|OH": "43215",
13506
+ "dallas|TX": "75201",
13507
+ "denver|CO": "80202",
13508
+ "detroit|MI": "48226",
13509
+ "fort_collins|CO": "80524",
13510
+ "fort_worth|TX": "76102",
13511
+ "houston|TX": "77002",
13512
+ "indianapolis|IN": "46204",
13513
+ "jacksonville|FL": "32202",
13514
+ "las_vegas|NV": "89101",
13515
+ "los_angeles|CA": "90012",
13516
+ "louisville|KY": "40202",
13517
+ "loveland|CO": "80537",
13518
+ "memphis|TN": "38103",
13519
+ "miami|FL": "33131",
13520
+ "minneapolis|MN": "55401",
13521
+ "nashville|TN": "37203",
13522
+ "new_york|NY": "10001",
13523
+ "orlando|FL": "32801",
13524
+ "philadelphia|PA": "19103",
13525
+ "phoenix|AZ": "85004",
13526
+ "portland|OR": "97205",
13527
+ "raleigh|NC": "27601",
13528
+ "richmond|VA": "23219",
13529
+ "sacramento|CA": "95814",
13530
+ "salt_lake_city|UT": "84101",
13531
+ "san_antonio|TX": "78205",
13532
+ "san_diego|CA": "92101",
13533
+ "san_francisco|CA": "94103",
13534
+ "san_jose|CA": "95113",
13535
+ "seattle|WA": "98101"
13536
+ };
13537
+ }
13538
+ });
13539
+
12837
13540
  // src/youtube/CaptionFetcher.ts
12838
13541
  async function fetchViaYoutubeTranscript(videoId) {
12839
13542
  try {
@@ -12846,6 +13549,11 @@ async function fetchViaYoutubeTranscript(videoId) {
12846
13549
  }));
12847
13550
  const text = chunks.map((c) => c.text).join(" ");
12848
13551
  const last = chunks[chunks.length - 1];
13552
+ const now = Date.now();
13553
+ await runWithCostContext(
13554
+ { ...currentCostContext(), op: "yt_transcription", subOp: "youtube-transcript" },
13555
+ () => recordKernelSession({ kernelSessionId: null, source: "yt_caption_tier", headlessSent: null, openedAtMs: now, closedAtMs: now })
13556
+ );
12849
13557
  return { videoId, text, chunks, durationMs: last ? last.timestamp[1] * 1e3 : 0, method: "youtube-transcript" };
12850
13558
  } catch {
12851
13559
  return null;
@@ -12897,162 +13605,214 @@ function parseTimedtextXml(xml) {
12897
13605
  }
12898
13606
  return results;
12899
13607
  }
12900
- async function fetchViaKernelInnertube(videoId) {
12901
- const kernelApiKey = browserServiceApiKey();
12902
- if (!kernelApiKey) return null;
12903
- const kernelProxyId = browserServiceProxyId();
12904
- const driver = new BrowserDriver();
12905
- const start = Date.now();
12906
- try {
12907
- await driver.launch({ kernelApiKey, kernelProxyId, headless: true, viewport: { width: 1280, height: 800 }, locale: "en-US" });
12908
- const page = driver.getPage();
12909
- await driver.navigateTo(`https://www.youtube.com/watch?v=${videoId}`);
12910
- await page.waitForFunction(
12911
- () => !!window.ytcfg,
12912
- { timeout: 2e4 }
12913
- );
12914
- const xml = await page.evaluate(async (vid) => {
12915
- const ANDROID_VERSION = "20.10.38";
12916
- const resp = await fetch("/youtubei/v1/player?prettyPrint=false", {
12917
- method: "POST",
12918
- headers: {
12919
- "Content-Type": "application/json",
12920
- "User-Agent": `com.google.android.youtube/${ANDROID_VERSION} (Linux; U; Android 14)`
12921
- },
12922
- body: JSON.stringify({
12923
- context: { client: { clientName: "ANDROID", clientVersion: ANDROID_VERSION } },
12924
- videoId: vid
12925
- })
13608
+ function isBotBlockStatus(status) {
13609
+ return status === "LOGIN_REQUIRED" || status === "AGE_VERIFICATION_REQUIRED";
13610
+ }
13611
+ async function probePlayer(page, videoId) {
13612
+ return page.evaluate(
13613
+ async ({ vid, clients }) => {
13614
+ let lastStatus = null;
13615
+ for (const client2 of clients) {
13616
+ const resp = await fetch("/youtubei/v1/player?prettyPrint=false", {
13617
+ method: "POST",
13618
+ headers: { "Content-Type": "application/json", "User-Agent": client2.userAgent },
13619
+ body: JSON.stringify({
13620
+ context: { client: { clientName: client2.name, clientVersion: client2.version } },
13621
+ videoId: vid
13622
+ })
13623
+ }).catch(() => null);
13624
+ if (!resp?.ok) {
13625
+ lastStatus = resp ? `player_http_${resp.status}` : "player_fetch_failed";
13626
+ continue;
13627
+ }
13628
+ const data = await resp.json();
13629
+ lastStatus = data?.playabilityStatus?.status ?? null;
13630
+ const audioFormats = (data?.streamingData?.adaptiveFormats ?? []).filter((f) => typeof f.url === "string" && f.url && typeof f.mimeType === "string" && f.mimeType.startsWith("audio/")).sort((a, b) => (Number(a.contentLength) || Infinity) - (Number(b.contentLength) || Infinity));
13631
+ const pick = audioFormats[0] ?? null;
13632
+ let captions = null;
13633
+ const tracks = data?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
13634
+ if (tracks.length > 0) {
13635
+ const track = tracks.find((t) => t.languageCode === "en") ?? tracks[0];
13636
+ const sep = track.baseUrl.includes("?") ? "&" : "?";
13637
+ const xmlResp = await fetch(`${track.baseUrl}${sep}fmt=json3`).catch(() => null);
13638
+ if (xmlResp?.ok) captions = await xmlResp.text();
13639
+ }
13640
+ if (!captions && !pick) continue;
13641
+ return {
13642
+ captions,
13643
+ status: lastStatus,
13644
+ audio: pick ? {
13645
+ url: pick.url,
13646
+ mimeType: pick.mimeType,
13647
+ bitrate: pick.bitrate ?? null,
13648
+ contentLength: pick.contentLength ? Number(pick.contentLength) || null : null,
13649
+ approxDurationMs: pick.approxDurationMs ? Number(pick.approxDurationMs) || null : null
13650
+ } : null
13651
+ };
13652
+ }
13653
+ return { captions: null, audio: null, status: lastStatus };
13654
+ },
13655
+ { vid: videoId, clients: INNERTUBE_CLIENTS }
13656
+ ).catch(() => null);
13657
+ }
13658
+ async function downloadWholeAudioStreamInOneRequestBecauseYouTubeAllowsOnlyOnePerIp(page, audio) {
13659
+ if (audio.contentLength && audio.contentLength > MAX_AUDIO_BYTES) {
13660
+ console.warn(`[CaptionFetcher] audio stream ${audio.contentLength} bytes exceeds ${MAX_AUDIO_BYTES} byte cap`);
13661
+ return null;
13662
+ }
13663
+ const sep = audio.url.includes("?") ? "&" : "?";
13664
+ const url = audio.contentLength ? `${audio.url}${sep}range=0-${audio.contentLength - 1}` : audio.url;
13665
+ const result = await page.evaluate(async (u) => {
13666
+ try {
13667
+ const resp = await fetch(u);
13668
+ if (!resp.ok) return { ok: false, error: `http_${resp.status}` };
13669
+ const blob = await resp.blob();
13670
+ const dataUrl = await new Promise((resolve, reject) => {
13671
+ const reader = new FileReader();
13672
+ reader.onload = () => resolve(reader.result);
13673
+ reader.onerror = () => reject(reader.error);
13674
+ reader.readAsDataURL(blob);
12926
13675
  });
12927
- if (!resp.ok) return null;
12928
- const data = await resp.json();
12929
- const tracks = data?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
12930
- if (tracks.length === 0) return null;
12931
- const track = tracks.find((t) => t.languageCode === "en") ?? tracks[0];
12932
- const sep = track.baseUrl.includes("?") ? "&" : "?";
12933
- const xmlResp = await fetch(`${track.baseUrl}${sep}fmt=json3`);
12934
- return xmlResp.ok ? xmlResp.text() : null;
12935
- }, videoId);
12936
- if (!xml) return null;
12937
- let entries = parseTimedtextJson3(xml);
12938
- if (entries.length === 0) entries = parseTimedtextXml(xml);
12939
- if (entries.length === 0) return null;
12940
- const chunks = entries.map((e) => ({
12941
- timestamp: [e.start / 1e3, (e.start + e.dur) / 1e3],
12942
- text: e.text
12943
- }));
12944
- const text = chunks.map((c) => c.text).join(" ");
12945
- const last = chunks[chunks.length - 1];
12946
- return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-innertube" };
12947
- } catch {
13676
+ return { ok: true, base64: dataUrl.slice(dataUrl.indexOf(",") + 1) };
13677
+ } catch (e) {
13678
+ return { ok: false, error: String(e) };
13679
+ }
13680
+ }, url).catch((e) => ({ ok: false, error: String(e) }));
13681
+ if (!result.ok || !result.base64) {
13682
+ console.warn(`[CaptionFetcher] audio fetch failed (${result.error ?? "unknown"}) for a ${audio.contentLength ?? 0} byte stream`);
13683
+ return null;
13684
+ }
13685
+ const bytes = Buffer.from(result.base64, "base64");
13686
+ if (audio.contentLength && bytes.length < audio.contentLength) {
13687
+ console.warn(`[CaptionFetcher] partial audio: ${bytes.length} of ${audio.contentLength} bytes`);
12948
13688
  return null;
12949
- } finally {
12950
- await driver.close();
12951
13689
  }
13690
+ return { bytes, mimeType: audio.mimeType.split(";")[0].trim() };
12952
13691
  }
12953
- async function captureAudioViaMediaRecorder(page) {
12954
- const result = await page.evaluate((recordSecs) => {
12955
- return new Promise((resolve) => {
12956
- const video = document.querySelector("video");
12957
- if (!video) {
12958
- resolve({ ok: false, error: "no video element" });
12959
- return;
12960
- }
12961
- video.muted = false;
12962
- video.volume = 1;
12963
- try {
12964
- const ctx = new AudioContext();
12965
- const src = ctx.createMediaElementSource(video);
12966
- const dest = ctx.createMediaStreamDestination();
12967
- src.connect(dest);
12968
- src.connect(ctx.destination);
12969
- const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "audio/ogg";
12970
- const recorder = new MediaRecorder(dest.stream, { mimeType, audioBitsPerSecond: 48e3 });
12971
- const chunks = [];
12972
- recorder.ondataavailable = (e) => {
12973
- if (e.data.size > 0) chunks.push(e.data);
12974
- };
12975
- recorder.onstop = async () => {
12976
- const blob = new Blob(chunks, { type: mimeType });
12977
- const ab = await blob.arrayBuffer();
12978
- const arr = new Uint8Array(ab);
12979
- let binary = "";
12980
- const chunkSize = 8192;
12981
- for (let i = 0; i < arr.length; i += chunkSize) {
12982
- binary += String.fromCharCode(...arr.slice(i, Math.min(i + chunkSize, arr.length)));
12983
- }
12984
- resolve({ ok: true, base64: btoa(binary), size: ab.byteLength, mimeType });
12985
- };
12986
- recorder.start(1e3);
12987
- video.playbackRate = 2;
12988
- video.play().catch(() => void 0);
12989
- setTimeout(() => {
12990
- recorder.stop();
12991
- ctx.close();
12992
- }, recordSecs * 1e3);
12993
- } catch (e) {
12994
- resolve({ ok: false, error: String(e) });
12995
- }
12996
- });
12997
- }, WHISPER_RECORD_SECONDS);
12998
- if (!result.ok || !result.base64 || !result.mimeType) return null;
12999
- return { bytes: Buffer.from(result.base64, "base64"), mimeType: result.mimeType };
13000
- }
13001
- async function attemptKernelWhisper(videoId, kernelApiKey, kernelProxyId, falKey, start) {
13002
- const driver = new BrowserDriver();
13692
+ async function transcribeWithWizper(videoId, audio, startMs) {
13693
+ const falKey = process.env.FAL_KEY;
13694
+ if (!falKey) return null;
13003
13695
  try {
13004
- await driver.launch({ kernelApiKey, kernelProxyId, headless: true, viewport: { width: 1280, height: 800 }, locale: "en-US" });
13005
- const page = driver.getPage();
13006
- await driver.navigateTo(`https://www.youtube.com/watch?v=${videoId}`);
13007
- await page.waitForFunction(
13008
- () => !!window.ytcfg,
13009
- { timeout: 2e4 }
13010
- );
13011
- const playStatus = await page.evaluate(() => {
13012
- const w = window;
13013
- return w.ytInitialPlayerResponse?.playabilityStatus?.status;
13014
- });
13015
- if (playStatus !== "OK") {
13016
- await driver.close();
13017
- return null;
13018
- }
13019
- const audioInfo = await captureAudioViaMediaRecorder(page);
13020
- await driver.close();
13021
- if (!audioInfo || audioInfo.bytes.length < 1e4) return null;
13022
13696
  import_client3.fal.config({ credentials: falKey });
13023
- const ext = audioInfo.mimeType.includes("ogg") ? "ogg" : "webm";
13024
- const audioFile = new File([new Uint8Array(audioInfo.bytes)], `audio.${ext}`, { type: audioInfo.mimeType });
13697
+ const ext = audio.mimeType.includes("webm") ? "webm" : audio.mimeType.includes("mp4") ? "m4a" : "audio";
13698
+ const audioFile = new File([new Uint8Array(audio.bytes)], `audio.${ext}`, { type: audio.mimeType });
13025
13699
  const uploadedUrl = await import_client3.fal.storage.upload(audioFile);
13700
+ const subscribeStartMs = Date.now();
13026
13701
  const result = await import_client3.fal.subscribe("fal-ai/wizper", {
13027
- input: { audio_url: uploadedUrl, task: "transcribe", language: "en" },
13702
+ input: { audio_url: uploadedUrl, task: "transcribe", language: "en", chunk_level: "segment" },
13028
13703
  logs: false,
13029
13704
  pollInterval: 3e3
13030
13705
  });
13706
+ const subscribeDurationMs = Date.now() - subscribeStartMs;
13031
13707
  const data = result.data;
13032
13708
  const chunks = (data.chunks ?? []).map((c) => ({
13033
13709
  timestamp: c.timestamp,
13034
13710
  text: c.text.trim()
13035
13711
  }));
13036
- const audioSec = chunks.length ? chunks[chunks.length - 1]?.timestamp?.[1] ?? 0 : 0;
13037
- void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: audioSec / 60, unitType: "audio_min" });
13712
+ void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: subscribeDurationMs / 1e3, unitType: "compute_sec" });
13038
13713
  const text = data.text ?? chunks.map((c) => c.text).join(" ");
13039
13714
  if (!text) return null;
13040
- return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-whisper" };
13041
- } catch {
13042
- await driver.close();
13715
+ return { videoId, text, chunks, durationMs: Date.now() - startMs, method: "browser-whisper" };
13716
+ } catch (err) {
13717
+ console.warn(`[CaptionFetcher] wizper transcription failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
13043
13718
  return null;
13044
13719
  }
13045
13720
  }
13046
- async function fetchViaKernelWhisper(videoId) {
13721
+ async function resolveResidentialProxy(kernelApiKey) {
13722
+ try {
13723
+ const resolved = await resolveKernelProxyId({
13724
+ kernelApiKey,
13725
+ proxyMode: "location",
13726
+ location: YT_RESIDENTIAL_LOCATION,
13727
+ gl: "us",
13728
+ fresh: true
13729
+ });
13730
+ return { kernelProxyId: resolved.kernelProxyId, disposableProxyId: resolved.disposableProxyId };
13731
+ } catch (err) {
13732
+ console.warn(`[CaptionFetcher] residential proxy resolution failed: ${err instanceof Error ? err.message : String(err)}`);
13733
+ return {};
13734
+ }
13735
+ }
13736
+ async function probeAndExtract(videoId, opts) {
13047
13737
  const kernelApiKey = browserServiceApiKey();
13048
- const kernelProxyId = browserServiceProxyId();
13049
- const falKey = process.env.FAL_KEY;
13050
- if (!kernelApiKey || !falKey) return null;
13738
+ if (!kernelApiKey) return null;
13739
+ const kernelProxyId = opts?.kernelProxyId ?? browserServiceProxyId();
13740
+ const driver = new BrowserDriver();
13741
+ return runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: "browser-innertube" }, async () => {
13742
+ let entries = [];
13743
+ let audio = null;
13744
+ let retryWithFreshIp = false;
13745
+ try {
13746
+ await driver.launch({ kernelApiKey, kernelProxyId, headless: true, headlessMode: "headless", viewport: { width: 1280, height: 800 }, locale: "en-US" });
13747
+ const page = driver.getPage();
13748
+ await driver.navigateTo(`https://www.youtube.com/watch?v=${videoId}`);
13749
+ await page.waitForFunction(
13750
+ () => !!window.ytcfg,
13751
+ { timeout: 2e4 }
13752
+ );
13753
+ const probe = await probePlayer(page, videoId);
13754
+ if (!probe) return null;
13755
+ if (probe.captions && !opts?.skipCaptions) {
13756
+ entries = parseTimedtextJson3(probe.captions);
13757
+ if (entries.length === 0) entries = parseTimedtextXml(probe.captions);
13758
+ }
13759
+ if (entries.length === 0) {
13760
+ if (!probe.audio) {
13761
+ retryWithFreshIp = isBotBlockStatus(probe.status);
13762
+ console.warn(`[CaptionFetcher] no captions and no audio stream for ${videoId} (playability=${probe.status ?? "unknown"}, proxied=${Boolean(kernelProxyId)})`);
13763
+ return { entries, audio: null, retryWithFreshIp };
13764
+ }
13765
+ audio = await downloadWholeAudioStreamInOneRequestBecauseYouTubeAllowsOnlyOnePerIp(page, probe.audio);
13766
+ if (!audio) return { entries, audio: null, retryWithFreshIp: true };
13767
+ }
13768
+ } catch (err) {
13769
+ console.warn(`[CaptionFetcher] kernel session failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
13770
+ return null;
13771
+ } finally {
13772
+ const servedSubOp = audio ? "browser-whisper" : "browser-innertube";
13773
+ await runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: servedSubOp }, () => driver.close());
13774
+ }
13775
+ return { entries, audio, retryWithFreshIp };
13776
+ });
13777
+ }
13778
+ async function extractWithProxyEscalation(videoId, opts) {
13779
+ const direct = await probeAndExtract(videoId, opts);
13780
+ if (direct && (direct.entries.length > 0 || direct.audio)) return direct;
13781
+ const shouldEscalate = direct === null || direct.retryWithFreshIp;
13782
+ if (!shouldEscalate) return direct;
13783
+ const kernelApiKey = browserServiceApiKey();
13784
+ if (!kernelApiKey) return direct;
13785
+ const { kernelProxyId, disposableProxyId } = await resolveResidentialProxy(kernelApiKey);
13786
+ if (!kernelProxyId) return direct;
13787
+ try {
13788
+ console.warn(`[CaptionFetcher] ${videoId}: retrying through residential proxy after bot block`);
13789
+ const viaProxy = await probeAndExtract(videoId, { ...opts, kernelProxyId });
13790
+ return viaProxy ?? direct;
13791
+ } finally {
13792
+ if (disposableProxyId) {
13793
+ await deleteKernelProxyId(kernelApiKey, disposableProxyId).catch(
13794
+ (err) => console.warn(`[CaptionFetcher] failed to delete disposable proxy: ${err instanceof Error ? err.message : String(err)}`)
13795
+ );
13796
+ }
13797
+ }
13798
+ }
13799
+ async function fetchViaKernelSession(videoId) {
13051
13800
  const start = Date.now();
13052
- for (let attempt = 0; attempt < 4; attempt++) {
13053
- if (attempt > 0) await new Promise((r) => setTimeout(r, 2e3 * attempt));
13054
- const result = await attemptKernelWhisper(videoId, kernelApiKey, kernelProxyId, falKey, start);
13055
- if (result) return result;
13801
+ const outcome = await extractWithProxyEscalation(videoId);
13802
+ if (!outcome) return null;
13803
+ if (outcome.entries.length > 0) {
13804
+ const chunks = outcome.entries.map((e) => ({
13805
+ timestamp: [e.start / 1e3, (e.start + e.dur) / 1e3],
13806
+ text: e.text
13807
+ }));
13808
+ const text = chunks.map((c) => c.text).join(" ");
13809
+ return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-innertube" };
13810
+ }
13811
+ if (outcome.audio) {
13812
+ return runWithCostContext(
13813
+ { ...currentCostContext(), op: "yt_transcription", subOp: "browser-whisper" },
13814
+ () => transcribeWithWizper(videoId, outcome.audio, start)
13815
+ );
13056
13816
  }
13057
13817
  return null;
13058
13818
  }
@@ -13074,21 +13834,29 @@ function finalizeCaptions(result) {
13074
13834
  async function fetchCaptions(videoId) {
13075
13835
  const primary = await fetchViaYoutubeTranscript(videoId);
13076
13836
  if (primary) return finalizeCaptions(primary);
13077
- const kernel = await fetchViaKernelInnertube(videoId);
13078
- if (kernel) return finalizeCaptions(kernel);
13079
- const whisper = await fetchViaKernelWhisper(videoId);
13080
- if (whisper) return finalizeCaptions(whisper);
13081
- throw new Error(`No captions available for ${videoId} \u2014 all three tiers failed`);
13837
+ const result = await fetchViaKernelSession(videoId);
13838
+ if (result) return finalizeCaptions(result);
13839
+ throw new Error(
13840
+ `Could not transcribe ${videoId}. It has no caption track, and its audio stream could not be downloaded \u2014 YouTube permits a single media request per IP, so streams that do not fit in one response are unreachable. Captioned videos of any length still work.`
13841
+ );
13082
13842
  }
13083
- var import_client3, WHISPER_RECORD_SECONDS;
13843
+ var import_client3, INNERTUBE_CLIENTS, MAX_AUDIO_BYTES, YT_RESIDENTIAL_LOCATION;
13084
13844
  var init_CaptionFetcher = __esm({
13085
13845
  "src/youtube/CaptionFetcher.ts"() {
13086
13846
  "use strict";
13087
13847
  init_BrowserDriver();
13088
13848
  init_browser_service_env();
13849
+ init_kernel_proxy_resolver();
13089
13850
  import_client3 = require("@fal-ai/client");
13090
13851
  init_cost_telemetry();
13091
- WHISPER_RECORD_SECONDS = 90;
13852
+ init_cost_context();
13853
+ INNERTUBE_CLIENTS = [
13854
+ { name: "ANDROID", version: "20.10.38", userAgent: "com.google.android.youtube/20.10.38 (Linux; U; Android 14)" },
13855
+ { name: "IOS", version: "20.10.4", userAgent: "com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)" },
13856
+ { name: "ANDROID_VR", version: "1.62.27", userAgent: "com.google.android.apps.youtube.vr.oculus/1.62.27 (Linux; U; Android 12L) gzip" }
13857
+ ];
13858
+ MAX_AUDIO_BYTES = 96 * 1024 * 1024;
13859
+ YT_RESIDENTIAL_LOCATION = process.env.YOUTUBE_PROXY_LOCATION ?? "Austin, Texas";
13092
13860
  }
13093
13861
  });
13094
13862
 
@@ -13113,7 +13881,7 @@ var init_api_auth = __esm({
13113
13881
  });
13114
13882
 
13115
13883
  // src/api/server-schemas.ts
13116
- var import_zod13, HarvestBodySchema, ExtractUrlBodySchema, MapUrlsBodySchema, ExtractSiteBodySchema, YoutubeHarvestBodySchema, YoutubeTranscribeBodySchema;
13884
+ var import_zod13, HarvestBodySchema, ExtractUrlBodySchema, DiffPageBodySchema, MapUrlsBodySchema, ExtractSiteBodySchema, YoutubeHarvestBodySchema, YoutubeTranscribeBodySchema;
13117
13885
  var init_server_schemas = __esm({
13118
13886
  "src/api/server-schemas.ts"() {
13119
13887
  "use strict";
@@ -13144,6 +13912,11 @@ var init_server_schemas = __esm({
13144
13912
  depositToVault: import_zod13.z.boolean().optional(),
13145
13913
  vaultName: import_zod13.z.string().trim().min(1).max(120).optional()
13146
13914
  });
13915
+ DiffPageBodySchema = import_zod13.z.object({
13916
+ url: import_zod13.z.string().min(1, "url is required"),
13917
+ allowLocal: import_zod13.z.boolean().optional(),
13918
+ resetBaseline: import_zod13.z.boolean().optional()
13919
+ });
13147
13920
  MapUrlsBodySchema = import_zod13.z.object({
13148
13921
  url: import_zod13.z.string().min(1, "url is required"),
13149
13922
  maxUrls: import_zod13.z.number().int().min(1).max(2e3).optional(),
@@ -13419,6 +14192,7 @@ var init_youtube_routes = __esm({
13419
14192
  init_api_auth();
13420
14193
  init_server_schemas();
13421
14194
  init_concurrency_gates();
14195
+ init_cost_context();
13422
14196
  youtubeApp = new import_hono3.Hono();
13423
14197
  youtubeApp.post("/harvest", createApiKeyAuth(), async (c) => {
13424
14198
  const raw = await c.req.json().catch(() => ({}));
@@ -13496,7 +14270,7 @@ var init_youtube_routes = __esm({
13496
14270
  const { ok: trOk, balance_mc: trBal } = await debitMc(user.id, holdMc, LedgerOperation.TRANSCRIPTION_HOLD, id);
13497
14271
  if (!trOk) return c.json(insufficientBalanceResponse(trBal, holdMc), 402);
13498
14272
  debited = true;
13499
- const result = await fetchCaptions(id);
14273
+ const result = await runWithCostContext({ ...currentCostContext(), op: "yt_transcription", userId: user.id }, () => fetchCaptions(id));
13500
14274
  const lastChunk = result.chunks?.[result.chunks.length - 1];
13501
14275
  const chunkSecs = lastChunk && Number.isFinite(lastChunk.timestamp[1]) ? lastChunk.timestamp[1] : 0;
13502
14276
  const durationSecs = Number.isFinite(result.durationMs) ? result.durationMs / 1e3 : 0;
@@ -14317,755 +15091,198 @@ function extractCollatedResults(payload) {
14317
15091
  page_name: r.page_name ?? snapshot?.page_name ?? "",
14318
15092
  is_active: Boolean(r.is_active),
14319
15093
  collation_count: typeof r.collation_count === "number" ? r.collation_count : null,
14320
- snapshot
14321
- });
14322
- }
14323
- }
14324
- return results;
14325
- }
14326
- async function collectAdLibraryResults(page, url, maxResults, opts = {}) {
14327
- const captureMs = opts.captureMs ?? 3e4;
14328
- const collected = [];
14329
- const seen = /* @__PURE__ */ new Set();
14330
- const handler = (resp) => {
14331
- if (!resp.url().includes("/api/graphql")) return;
14332
- const friendlyName = (resp.request().postData() ?? "").match(/fb_api_req_friendly_name=([^&]+)/)?.[1];
14333
- if (friendlyName !== AD_LIBRARY_QUERY) return;
14334
- void resp.text().then((text) => {
14335
- for (const payload of parseFbGraphqlJson(text)) {
14336
- for (const result of extractCollatedResults(payload)) {
14337
- if (seen.has(result.ad_archive_id)) continue;
14338
- seen.add(result.ad_archive_id);
14339
- collected.push(result);
14340
- }
14341
- }
14342
- }).catch(() => void 0);
14343
- };
14344
- page.on("response", handler);
14345
- try {
14346
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
14347
- const deadline = Date.now() + captureMs;
14348
- let lastCount = -1;
14349
- let stableRounds = 0;
14350
- while (Date.now() < deadline && collected.length < maxResults) {
14351
- await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)).catch(() => void 0);
14352
- await page.waitForTimeout(2e3);
14353
- if (collected.length === lastCount) {
14354
- stableRounds++;
14355
- if (stableRounds >= 2 && collected.length > 0) break;
14356
- } else {
14357
- stableRounds = 0;
14358
- }
14359
- lastCount = collected.length;
14360
- }
14361
- } finally {
14362
- page.off("response", handler);
14363
- }
14364
- return collected.slice(0, maxResults);
14365
- }
14366
- function advertisersFromResults(results, maxResults) {
14367
- const byPage = /* @__PURE__ */ new Map();
14368
- for (const r of results) {
14369
- if (!r.page_id || !r.page_name) continue;
14370
- const collation = typeof r.collation_count === "number" && r.collation_count > 0 ? r.collation_count : 0;
14371
- const existing = byPage.get(r.page_id);
14372
- if (existing) {
14373
- existing.resultCount++;
14374
- existing.maxCollation = Math.max(existing.maxCollation, collation);
14375
- } else {
14376
- byPage.set(r.page_id, { pageName: r.page_name, pageId: r.page_id, sampleLibraryId: r.ad_archive_id, maxCollation: collation, resultCount: 1 });
14377
- }
14378
- }
14379
- return [...byPage.values()].map((e) => ({ pageName: e.pageName, pageId: e.pageId, sampleLibraryId: e.sampleLibraryId, adCount: Math.max(e.maxCollation, e.resultCount) })).sort((a, b) => b.adCount - a.adCount).slice(0, maxResults);
14380
- }
14381
- var AD_LIBRARY_QUERY;
14382
- var init_FacebookAdGraphql = __esm({
14383
- "src/extractor/FacebookAdGraphql.ts"() {
14384
- "use strict";
14385
- AD_LIBRARY_QUERY = "AdLibrarySearchPaginationQuery";
14386
- }
14387
- });
14388
-
14389
- // src/extractor/FacebookOrganicVideoExtractor.ts
14390
- function htmlDecode(value) {
14391
- return value.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#039;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
14392
- }
14393
- function decodeEscapedValue(raw) {
14394
- let out = raw;
14395
- try {
14396
- out = JSON.parse(`"${raw.replace(/"/g, '\\"')}"`);
14397
- } catch {
14398
- out = raw.replace(/\\u0025/g, "%").replace(/\\u0026/g, "&").replace(/\\u003D/g, "=").replace(/\\u003d/g, "=").replace(/\\"/g, '"').replace(/\\\//g, "/");
14399
- }
14400
- return htmlDecode(out);
14401
- }
14402
- function facebookVideoIdFromUrl(url) {
14403
- try {
14404
- const parsed = new URL(url);
14405
- const path6 = parsed.pathname;
14406
- const direct = path6.match(/\/(?:reel|videos|watch)\/(?:[^/]+\/)?(\d{8,})/i);
14407
- if (direct) return direct[1];
14408
- const queryVideoId = parsed.searchParams.get("v") ?? parsed.searchParams.get("video_id");
14409
- if (queryVideoId && /^\d{8,}$/.test(queryVideoId)) return queryVideoId;
14410
- const numericTail = path6.match(/\/(\d{8,})\/?$/);
14411
- if (numericTail) return numericTail[1];
14412
- } catch {
14413
- }
14414
- return null;
14415
- }
14416
- function parseEfg(url) {
14417
- try {
14418
- const efg = new URL(url).searchParams.get("efg");
14419
- if (!efg) return null;
14420
- return JSON.parse(Buffer.from(efg, "base64").toString("utf8"));
14421
- } catch {
14422
- return null;
14423
- }
14424
- }
14425
- function manifestDurationSec(htmlWindow) {
14426
- const decoded = decodeEscapedValue(htmlWindow);
14427
- const match = decoded.match(/mediaPresentationDuration="PT([\d.]+)S"/);
14428
- return match ? Number(match[1]) : null;
14429
- }
14430
- function metadataContent(html, key) {
14431
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14432
- const re = new RegExp(`<meta\\s+[^>]*(?:property|name)=["']${escaped}["'][^>]*content=["']([^"']+)["'][^>]*>`, "i");
14433
- const m = html.match(re);
14434
- return m ? htmlDecode(m[1]).trim() : null;
14435
- }
14436
- function ownerNameFromHtml(html) {
14437
- const ogTitle = metadataContent(html, "og:title");
14438
- const pipe = ogTitle?.split("|").map((s) => s.trim()).filter(Boolean);
14439
- if (pipe && pipe.length > 1) return pipe[pipe.length - 1] ?? null;
14440
- return null;
14441
- }
14442
- function extractFacebookOrganicVideo(html, pageUrl, sourceUrl = pageUrl, quality = "best") {
14443
- const videoId = facebookVideoIdFromUrl(pageUrl) ?? facebookVideoIdFromUrl(sourceUrl);
14444
- const manifestIndex = videoId ? html.indexOf(`dash_mpd_debug.mpd?v=${videoId}`) : -1;
14445
- const searchWindow = manifestIndex >= 0 ? html.slice(Math.max(0, manifestIndex - 5e4), Math.min(html.length, manifestIndex + 5e4)) : html;
14446
- const targetDuration = manifestDurationSec(searchWindow);
14447
- const candidates = [];
14448
- const seen = /* @__PURE__ */ new Set();
14449
- const re = /browser_native_(sd|hd)_url\\?"\s*:\s*\\?"([^"<]+?)\\?"/g;
14450
- let match;
14451
- while ((match = re.exec(searchWindow)) !== null) {
14452
- const url = decodeEscapedValue(match[2]);
14453
- if (!url || seen.has(url)) continue;
14454
- seen.add(url);
14455
- const parsed = parseEfg(url);
14456
- let bitrate = null;
14457
- try {
14458
- const value = new URL(url).searchParams.get("bitrate");
14459
- bitrate = value ? Number(value) : null;
14460
- } catch {
14461
- }
14462
- candidates.push({
14463
- url,
14464
- quality: match[1],
14465
- bitrate: Number.isFinite(bitrate) ? bitrate : null,
14466
- durationSec: typeof parsed?.duration_s === "number" ? parsed.duration_s : null,
14467
- assetId: typeof parsed?.xpv_asset_id === "number" ? parsed.xpv_asset_id : null,
14468
- vencodeTag: typeof parsed?.vencode_tag === "string" ? parsed.vencode_tag : null
14469
- });
14470
- }
14471
- const targetCandidates = candidates.filter((candidate) => {
14472
- if (!targetDuration || candidate.durationSec == null) return true;
14473
- return Math.abs(candidate.durationSec - targetDuration) <= 2;
14474
- });
14475
- const selectable = targetCandidates.length ? targetCandidates : candidates;
14476
- const selected = [...selectable].filter((candidate) => quality === "best" || candidate.quality === quality).sort((a, b) => {
14477
- const aq = a.quality === "hd" ? 1 : 0;
14478
- const bq = b.quality === "hd" ? 1 : 0;
14479
- if (quality === "best" && aq !== bq) return bq - aq;
14480
- return (b.bitrate ?? 0) - (a.bitrate ?? 0);
14481
- })[0];
14482
- if (!selected) {
14483
- throw new Error("No progressive Facebook video URL was found in the rendered page state");
14484
- }
14485
- return {
14486
- sourceUrl,
14487
- pageUrl,
14488
- videoId,
14489
- title: metadataContent(html, "og:title") ?? metadataContent(html, "twitter:title"),
14490
- description: metadataContent(html, "og:description") ?? metadataContent(html, "description") ?? metadataContent(html, "twitter:description"),
14491
- ownerName: ownerNameFromHtml(html),
14492
- videoUrl: selected.url,
14493
- selectedQuality: selected.quality,
14494
- bitrate: selected.bitrate,
14495
- durationSec: targetDuration ?? selected.durationSec,
14496
- assetId: selected.assetId,
14497
- vencodeTag: selected.vencodeTag,
14498
- candidates: selectable,
14499
- extractedAt: (/* @__PURE__ */ new Date()).toISOString()
14500
- };
14501
- }
14502
- async function extractFacebookOrganicVideoFromPage(page, sourceUrl, quality = "best") {
14503
- await page.waitForLoadState("domcontentloaded", { timeout: 15e3 }).catch(() => {
14504
- });
14505
- await page.waitForTimeout(1500);
14506
- const html = await page.content();
14507
- return extractFacebookOrganicVideo(html, page.url(), sourceUrl, quality);
14508
- }
14509
- var init_FacebookOrganicVideoExtractor = __esm({
14510
- "src/extractor/FacebookOrganicVideoExtractor.ts"() {
14511
- "use strict";
14512
- }
14513
- });
14514
-
14515
- // src/locations.ts
14516
- var LOCATIONS;
14517
- var init_locations = __esm({
14518
- "src/locations.ts"() {
14519
- "use strict";
14520
- LOCATIONS = {
14521
- "austin": "Austin,Texas,United States",
14522
- "new york": "New York,New York,United States",
14523
- "new york city": "New York,New York,United States",
14524
- "nyc": "New York,New York,United States",
14525
- "los angeles": "Los Angeles,California,United States",
14526
- "la": "Los Angeles,California,United States",
14527
- "chicago": "Chicago,Illinois,United States",
14528
- "houston": "Houston,Texas,United States",
14529
- "phoenix": "Phoenix,Arizona,United States",
14530
- "philadelphia": "Philadelphia,Pennsylvania,United States",
14531
- "philly": "Philadelphia,Pennsylvania,United States",
14532
- "san antonio": "San Antonio,Texas,United States",
14533
- "dallas": "Dallas,Texas,United States",
14534
- "miami": "Miami,Florida,United States",
14535
- "seattle": "Seattle,Washington,United States",
14536
- "denver": "Denver,Colorado,United States",
14537
- "loveland": "Loveland,Colorado,United States",
14538
- "loveland co": "Loveland,Colorado,United States",
14539
- "fort collins": "Fort Collins,Colorado,United States",
14540
- "boulder": "Boulder,Colorado,United States",
14541
- "colorado springs": "Colorado Springs,Colorado,United States",
14542
- "boston": "Boston,Massachusetts,United States",
14543
- "atlanta": "Atlanta,Georgia,United States",
14544
- "san francisco": "San Francisco,California,United States",
14545
- "sf": "San Francisco,California,United States",
14546
- "portland": "Portland,Oregon,United States",
14547
- "las vegas": "Las Vegas,Nevada,United States",
14548
- "minneapolis": "Minneapolis,Minnesota,United States",
14549
- "detroit": "Detroit,Michigan,United States",
14550
- "nashville": "Nashville,Tennessee,United States",
14551
- "charlotte": "Charlotte,North Carolina,United States",
14552
- "orlando": "Orlando,Florida,United States",
14553
- "san diego": "San Diego,California,United States",
14554
- "baltimore": "Baltimore,Maryland,United States",
14555
- "sacramento": "Sacramento,California,United States",
14556
- "columbus": "Columbus,Ohio,United States",
14557
- "indianapolis": "Indianapolis,Indiana,United States",
14558
- "san jose": "San Jose,California,United States",
14559
- "fort worth": "Fort Worth,Texas,United States",
14560
- "jacksonville": "Jacksonville,Florida,United States",
14561
- "memphis": "Memphis,Tennessee,United States",
14562
- "louisville": "Louisville,Kentucky,United States",
14563
- "raleigh": "Raleigh,North Carolina,United States",
14564
- "richmond": "Richmond,Virginia,United States",
14565
- "salt lake city": "Salt Lake City,Utah,United States",
14566
- "toronto": "Toronto,Ontario,Canada",
14567
- "vancouver": "Vancouver,British Columbia,Canada",
14568
- "montreal": "Montreal,Quebec,Canada",
14569
- "calgary": "Calgary,Alberta,Canada",
14570
- "ottawa": "Ottawa,Ontario,Canada",
14571
- "london": "London,England,United Kingdom",
14572
- "manchester": "Manchester,England,United Kingdom",
14573
- "birmingham": "Birmingham,England,United Kingdom",
14574
- "edinburgh": "Edinburgh,Scotland,United Kingdom",
14575
- "glasgow": "Glasgow,Scotland,United Kingdom",
14576
- "leeds": "Leeds,England,United Kingdom",
14577
- "sydney": "Sydney,New South Wales,Australia",
14578
- "melbourne": "Melbourne,Victoria,Australia",
14579
- "brisbane": "Brisbane,Queensland,Australia",
14580
- "perth": "Perth,Western Australia,Australia",
14581
- "adelaide": "Adelaide,South Australia,Australia",
14582
- "dublin": "Dublin,Leinster,Ireland"
14583
- };
14584
- }
14585
- });
14586
-
14587
- // src/uule.ts
14588
- function encodeVarint(value) {
14589
- const bytes = [];
14590
- let remaining = value;
14591
- do {
14592
- let byte = remaining & 127;
14593
- remaining >>>= 7;
14594
- if (remaining > 0) byte |= 128;
14595
- bytes.push(byte);
14596
- } while (remaining > 0);
14597
- return bytes;
14598
- }
14599
- function encodeUule(name) {
14600
- const locationBytes = Buffer.from(name, "utf8");
14601
- const payload = Buffer.concat([
14602
- Buffer.from([8, 2, 16, 32, 34]),
14603
- Buffer.from(encodeVarint(locationBytes.length)),
14604
- locationBytes
14605
- ]);
14606
- return `w+${payload.toString("base64")}`;
14607
- }
14608
- function normalizeLocation(input) {
14609
- const raw = input.toLowerCase().trim();
14610
- if (LOCATIONS[raw]) return LOCATIONS[raw];
14611
- const beforeComma = raw.split(",")[0].trim();
14612
- if (beforeComma !== raw && LOCATIONS[beforeComma]) return LOCATIONS[beforeComma];
14613
- const withoutState = raw.replace(/\s+[a-z]{2}$/, "").trim();
14614
- if (withoutState !== raw && LOCATIONS[withoutState]) return LOCATIONS[withoutState];
14615
- return input;
14616
- }
14617
- var init_uule = __esm({
14618
- "src/uule.ts"() {
14619
- "use strict";
14620
- init_locations();
14621
- }
14622
- });
14623
-
14624
- // src/kernel-proxy-resolver.ts
14625
- function proxyIdSuffix2(proxyId) {
14626
- return proxyId ? proxyId.slice(-6) : null;
14627
- }
14628
- function resolution(source, proxyMode, proxyId, target, error, disposable = false) {
14629
- const proxyType = source === "disabled" ? "none" : source === "configured_fallback" ? "configured" : source === "isp_created" ? "isp" : source === "location_created" || source === "location_reused" ? "residential" : "unknown";
14630
- return {
14631
- kernelProxyId: proxyId,
14632
- ...disposable && proxyId ? { disposableProxyId: proxyId } : {},
14633
- resolution: {
14634
- source,
14635
- proxyType,
14636
- proxyMode,
14637
- proxyIdPresent: Boolean(proxyId),
14638
- proxyIdSuffix: proxyIdSuffix2(proxyId),
14639
- target,
14640
- error,
14641
- disposable
14642
- }
14643
- };
14644
- }
14645
- function normalizeStateName(value) {
14646
- return value.trim().toLowerCase().replace(/\s+/g, " ");
14647
- }
14648
- function normalizeCountryName(value) {
14649
- return value.trim().toLowerCase().replace(/\./g, "").replace(/\s+/g, " ");
14650
- }
14651
- function isUnitedStates(country) {
14652
- if (!country) return true;
14653
- const normalized = normalizeCountryName(country);
14654
- return normalized === "united states" || normalized === "united states of america" || normalized === "usa" || normalized === "us";
14655
- }
14656
- function stateCodeFor(region) {
14657
- const trimmed = region.trim();
14658
- if (/^[A-Za-z]{2}$/.test(trimmed)) return trimmed.toUpperCase();
14659
- return US_STATE_CODES[normalizeStateName(trimmed)] ?? null;
14660
- }
14661
- function kernelCityIdentifierCandidates(city) {
14662
- const ascii = city.normalize("NFKD").replace(/[^\x00-\x7F]/g, "").toLowerCase();
14663
- const words = ascii.split(/[^a-z0-9]+/).filter(Boolean);
14664
- const underscored = words.join("_");
14665
- const compact = words.join("");
14666
- return Array.from(new Set([underscored, compact].filter(Boolean)));
14667
- }
14668
- function proxyName(country, state, city) {
14669
- return city ? `mcp-serp-residential-${country.toLowerCase()}-${state.toLowerCase()}-${city}` : `mcp-serp-residential-${country.toLowerCase()}-${state.toLowerCase()}`;
14670
- }
14671
- function freshProxyName(baseName, attemptIndex) {
14672
- const stamp = `${Date.now()}-${attemptIndex ?? 0}-${Math.random().toString(36).slice(2, 8)}`;
14673
- return `${baseName}-fresh-${stamp}`;
14674
- }
14675
- function zipProxyName(zip) {
14676
- return `mcp-serp-residential-us-zip-${zip}`;
14677
- }
14678
- function parseKernelLocationProxyTarget(location2, gl) {
14679
- if (!location2 || gl.toLowerCase() !== "us") return null;
14680
- const canonicalLocation = normalizeLocation(location2);
14681
- let parts = canonicalLocation.split(",").map((part) => part.trim()).filter(Boolean);
14682
- if (parts.length > 1 && isUnitedStates(parts[parts.length - 1])) {
14683
- parts = parts.slice(0, -1);
14684
- }
14685
- if (parts.length === 1) {
14686
- const stateOnly = stateCodeFor(parts[0]);
14687
- if (!stateOnly) return null;
14688
- return {
14689
- canonicalLocation,
14690
- level: "state",
14691
- country: "US",
14692
- state: stateOnly,
14693
- city: "",
14694
- cityCandidates: [],
14695
- proxyName: proxyName("US", stateOnly),
14696
- config: {
14697
- country: "US",
14698
- state: stateOnly
14699
- }
14700
- };
14701
- }
14702
- const [city = "", region = ""] = parts;
14703
- if (!city || !region) return null;
14704
- const state = stateCodeFor(region);
14705
- if (!state) return null;
14706
- const cityCandidates = kernelCityIdentifierCandidates(city);
14707
- const primaryCity = cityCandidates[0];
14708
- if (!primaryCity) return null;
14709
- return {
14710
- canonicalLocation,
14711
- level: "city",
14712
- country: "US",
14713
- state,
14714
- city: primaryCity,
14715
- cityCandidates,
14716
- proxyName: proxyName("US", state, primaryCity),
14717
- config: {
14718
- country: "US",
14719
- state,
14720
- city: primaryCity
14721
- }
14722
- };
14723
- }
14724
- function cityZipKey(target) {
14725
- return `${target.city}|${target.state}`;
14726
- }
14727
- function knownZipFor(target, explicitZip) {
14728
- if (explicitZip && /^\d{5}$/.test(explicitZip)) return explicitZip;
14729
- return US_CITY_CENTER_ZIPS[cityZipKey(target)] ?? null;
14730
- }
14731
- function zipTarget(target, zip) {
14732
- return {
14733
- ...target,
14734
- level: "zip",
14735
- zip,
14736
- proxyName: zipProxyName(zip),
14737
- config: {
14738
- country: target.country,
14739
- state: target.state,
14740
- zip
14741
- }
14742
- };
14743
- }
14744
- function withProxyName(target, name) {
14745
- return {
14746
- ...target,
14747
- proxyName: name
14748
- };
14749
- }
14750
- function configMatches(config, target, city) {
14751
- if (target.level === "zip") {
14752
- return config?.country?.toUpperCase() === target.country && config?.zip === target.zip;
14753
- }
14754
- return config?.country?.toUpperCase() === target.country && config?.state?.toUpperCase() === target.state && (city ? config?.city === city : !config?.city);
14755
- }
14756
- function findExistingTargetProxy(proxies, target) {
14757
- return proxies.find((proxy) => proxy.type === "residential" && proxy.status !== "unavailable" && Boolean(proxy.id) && (proxy.name === target.proxyName || configMatches(proxy.config, target, target.level === "city" ? target.city : void 0))) ?? null;
14758
- }
14759
- function findExistingProxy(proxies, target) {
14760
- for (const city of target.cityCandidates) {
14761
- const name = proxyName(target.country, target.state, city);
14762
- const found = proxies.find((proxy) => proxy.type === "residential" && proxy.status !== "unavailable" && Boolean(proxy.id) && (proxy.name === name || configMatches(proxy.config, target, city)));
14763
- if (found) return found;
14764
- }
14765
- return null;
14766
- }
14767
- function stateTarget(target) {
14768
- return {
14769
- ...target,
14770
- level: "state",
14771
- proxyName: proxyName(target.country, target.state),
14772
- config: {
14773
- country: target.country,
14774
- state: target.state
14775
- }
14776
- };
14777
- }
14778
- function findExistingStateProxy(proxies, target) {
14779
- const name = proxyName(target.country, target.state);
14780
- return proxies.find((proxy) => proxy.type === "residential" && proxy.status !== "unavailable" && Boolean(proxy.id) && (proxy.name === name || configMatches(proxy.config, target))) ?? null;
14781
- }
14782
- function escalatedTargetLevel(target, attemptIndex) {
14783
- return stateTarget(target);
14784
- }
14785
- function errorText2(err) {
14786
- return err instanceof Error ? err.message : String(err);
14787
- }
14788
- function freshTargetCandidates(target, explicitZip, attemptIndex) {
14789
- const out = [];
14790
- const zip = knownZipFor(target, explicitZip);
14791
- if (zip) {
14792
- const targetZip = zipTarget(target, zip);
14793
- out.push(withProxyName(targetZip, freshProxyName(targetZip.proxyName, attemptIndex)));
14794
- }
14795
- for (const city of target.cityCandidates) {
14796
- const cityTarget = {
14797
- ...target,
14798
- level: "city",
14799
- city,
14800
- proxyName: proxyName(target.country, target.state, city),
14801
- config: {
14802
- country: target.country,
14803
- state: target.state,
14804
- city
14805
- }
14806
- };
14807
- out.push(withProxyName(cityTarget, freshProxyName(cityTarget.proxyName, attemptIndex)));
14808
- }
14809
- const fallbackTarget = stateTarget(target);
14810
- out.push(withProxyName(fallbackTarget, freshProxyName(fallbackTarget.proxyName, attemptIndex)));
14811
- return out;
14812
- }
14813
- async function createFreshLocationProxy(kernel, options, target) {
14814
- const createErrors = [];
14815
- for (const candidate of freshTargetCandidates(target, options.proxyZip, options.attemptIndex)) {
14816
- try {
14817
- const created = await kernel.proxies.create({
14818
- type: "residential",
14819
- name: candidate.proxyName,
14820
- config: candidate.level === "zip" ? { country: candidate.country, zip: candidate.zip } : candidate.config
15094
+ snapshot
14821
15095
  });
14822
- if (created.id) {
14823
- return resolution("location_created", options.proxyMode, created.id, candidate, null, true);
14824
- }
14825
- createErrors.push(`${candidate.proxyName}: Kernel did not return a proxy id`);
14826
- } catch (err) {
14827
- createErrors.push(`${candidate.proxyName}: ${errorText2(err)}`);
14828
15096
  }
14829
15097
  }
14830
- if (process.env.KERNEL_PROXY_TYPE !== "residential") {
14831
- const ispTarget = stateTarget(target);
14832
- try {
14833
- const created = await kernel.proxies.create({
14834
- type: "isp",
14835
- name: freshProxyName(`mcp-serp-isp-${ispTarget.country.toLowerCase()}-${(ispTarget.state ?? "").toLowerCase()}`, options.attemptIndex),
14836
- config: { country: ispTarget.country, ...ispTarget.state ? { state: ispTarget.state } : {} }
14837
- });
14838
- if (created.id) {
14839
- return resolution("isp_created", options.proxyMode, created.id, ispTarget, null, true);
15098
+ return results;
15099
+ }
15100
+ async function collectAdLibraryResults(page, url, maxResults, opts = {}) {
15101
+ const captureMs = opts.captureMs ?? 3e4;
15102
+ const collected = [];
15103
+ const seen = /* @__PURE__ */ new Set();
15104
+ const handler = (resp) => {
15105
+ if (!resp.url().includes("/api/graphql")) return;
15106
+ const friendlyName = (resp.request().postData() ?? "").match(/fb_api_req_friendly_name=([^&]+)/)?.[1];
15107
+ if (friendlyName !== AD_LIBRARY_QUERY) return;
15108
+ void resp.text().then((text) => {
15109
+ for (const payload of parseFbGraphqlJson(text)) {
15110
+ for (const result of extractCollatedResults(payload)) {
15111
+ if (seen.has(result.ad_archive_id)) continue;
15112
+ seen.add(result.ad_archive_id);
15113
+ collected.push(result);
15114
+ }
14840
15115
  }
14841
- createErrors.push("isp: Kernel did not return a proxy id");
14842
- } catch (err) {
14843
- createErrors.push(`isp: ${errorText2(err)}`);
15116
+ }).catch(() => void 0);
15117
+ };
15118
+ page.on("response", handler);
15119
+ try {
15120
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
15121
+ const deadline = Date.now() + captureMs;
15122
+ let lastCount = -1;
15123
+ let stableRounds = 0;
15124
+ while (Date.now() < deadline && collected.length < maxResults) {
15125
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)).catch(() => void 0);
15126
+ await page.waitForTimeout(2e3);
15127
+ if (collected.length === lastCount) {
15128
+ stableRounds++;
15129
+ if (stableRounds >= 2 && collected.length > 0) break;
15130
+ } else {
15131
+ stableRounds = 0;
15132
+ }
15133
+ lastCount = collected.length;
14844
15134
  }
15135
+ } finally {
15136
+ page.off("response", handler);
14845
15137
  }
14846
- return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, createErrors.join(" | "));
15138
+ return collected.slice(0, maxResults);
14847
15139
  }
14848
- async function deleteKernelProxyId(kernelApiKey, proxyId) {
14849
- if (!kernelApiKey || !proxyId) return;
14850
- const kernel = new import_sdk6.default({ apiKey: kernelApiKey });
14851
- await kernel.proxies.delete(proxyId);
15140
+ function advertisersFromResults(results, maxResults) {
15141
+ const byPage = /* @__PURE__ */ new Map();
15142
+ for (const r of results) {
15143
+ if (!r.page_id || !r.page_name) continue;
15144
+ const collation = typeof r.collation_count === "number" && r.collation_count > 0 ? r.collation_count : 0;
15145
+ const existing = byPage.get(r.page_id);
15146
+ if (existing) {
15147
+ existing.resultCount++;
15148
+ existing.maxCollation = Math.max(existing.maxCollation, collation);
15149
+ } else {
15150
+ byPage.set(r.page_id, { pageName: r.page_name, pageId: r.page_id, sampleLibraryId: r.ad_archive_id, maxCollation: collation, resultCount: 1 });
15151
+ }
15152
+ }
15153
+ return [...byPage.values()].map((e) => ({ pageName: e.pageName, pageId: e.pageId, sampleLibraryId: e.sampleLibraryId, adCount: Math.max(e.maxCollation, e.resultCount) })).sort((a, b) => b.adCount - a.adCount).slice(0, maxResults);
14852
15154
  }
14853
- async function resolveKernelProxyId(options) {
14854
- if (options.proxyMode === "none") {
14855
- return resolution("disabled", options.proxyMode, void 0, null, null);
15155
+ var AD_LIBRARY_QUERY;
15156
+ var init_FacebookAdGraphql = __esm({
15157
+ "src/extractor/FacebookAdGraphql.ts"() {
15158
+ "use strict";
15159
+ AD_LIBRARY_QUERY = "AdLibrarySearchPaginationQuery";
14856
15160
  }
14857
- if (options.proxyMode === "configured") {
14858
- return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, null, null);
15161
+ });
15162
+
15163
+ // src/extractor/FacebookOrganicVideoExtractor.ts
15164
+ function htmlDecode(value) {
15165
+ return value.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#039;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
15166
+ }
15167
+ function decodeEscapedValue(raw) {
15168
+ let out = raw;
15169
+ try {
15170
+ out = JSON.parse(`"${raw.replace(/"/g, '\\"')}"`);
15171
+ } catch {
15172
+ out = raw.replace(/\\u0025/g, "%").replace(/\\u0026/g, "&").replace(/\\u003D/g, "=").replace(/\\u003d/g, "=").replace(/\\"/g, '"').replace(/\\\//g, "/");
14859
15173
  }
14860
- const target = parseKernelLocationProxyTarget(options.location, options.gl);
14861
- if (!target || !options.kernelApiKey) {
14862
- return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, target ? null : "location could not be normalized to a US city/state proxy target");
15174
+ return htmlDecode(out);
15175
+ }
15176
+ function facebookVideoIdFromUrl(url) {
15177
+ try {
15178
+ const parsed = new URL(url);
15179
+ const path6 = parsed.pathname;
15180
+ const direct = path6.match(/\/(?:reel|videos|watch)\/(?:[^/]+\/)?(\d{8,})/i);
15181
+ if (direct) return direct[1];
15182
+ const queryVideoId = parsed.searchParams.get("v") ?? parsed.searchParams.get("video_id");
15183
+ if (queryVideoId && /^\d{8,}$/.test(queryVideoId)) return queryVideoId;
15184
+ const numericTail = path6.match(/\/(\d{8,})\/?$/);
15185
+ if (numericTail) return numericTail[1];
15186
+ } catch {
14863
15187
  }
14864
- const kernel = new import_sdk6.default({ apiKey: options.kernelApiKey });
15188
+ return null;
15189
+ }
15190
+ function parseEfg(url) {
14865
15191
  try {
14866
- const attemptIndex = options.attemptIndex ?? 0;
14867
- if (options.fresh) {
14868
- return await createFreshLocationProxy(kernel, options, target);
14869
- }
14870
- if (attemptIndex >= 1) {
14871
- const escalatedTarget = escalatedTargetLevel(target, attemptIndex);
14872
- const createErrors2 = [];
14873
- try {
14874
- const created = await kernel.proxies.create({
14875
- type: "residential",
14876
- name: escalatedTarget.proxyName,
14877
- config: escalatedTarget.config
14878
- });
14879
- if (created.id) {
14880
- return resolution("location_created", options.proxyMode, created.id, escalatedTarget, null);
14881
- }
14882
- createErrors2.push(`${escalatedTarget.state}: Kernel did not return a proxy id`);
14883
- } catch (err) {
14884
- createErrors2.push(`${escalatedTarget.state}: ${errorText2(err)}`);
14885
- }
14886
- return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, escalatedTarget, createErrors2.join(" | "));
14887
- }
14888
- const proxies = await kernel.proxies.list();
14889
- const zip = knownZipFor(target, options.proxyZip);
14890
- const createErrors = [];
14891
- if (zip) {
14892
- const targetZip = zipTarget(target, zip);
14893
- const existingZip = findExistingTargetProxy(proxies, targetZip);
14894
- if (existingZip?.id) {
14895
- return resolution("location_reused", options.proxyMode, existingZip.id, targetZip, null);
14896
- }
14897
- try {
14898
- const created = await kernel.proxies.create({
14899
- type: "residential",
14900
- name: targetZip.proxyName,
14901
- config: {
14902
- country: targetZip.country,
14903
- zip
14904
- }
14905
- });
14906
- if (created.id) {
14907
- return resolution("location_created", options.proxyMode, created.id, targetZip, null);
14908
- }
14909
- createErrors.push(`${zip}: Kernel did not return a proxy id`);
14910
- } catch (err) {
14911
- createErrors.push(`${zip}: ${errorText2(err)}`);
14912
- }
14913
- }
14914
- const existing = findExistingProxy(proxies, target);
14915
- if (existing?.id) {
14916
- return resolution("location_reused", options.proxyMode, existing.id, target, createErrors.join(" | ") || null);
14917
- }
14918
- for (const city of target.cityCandidates) {
14919
- try {
14920
- const created = await kernel.proxies.create({
14921
- type: "residential",
14922
- name: proxyName(target.country, target.state, city),
14923
- config: {
14924
- country: target.country,
14925
- state: target.state,
14926
- city
14927
- }
14928
- });
14929
- if (created.id) {
14930
- return resolution("location_created", options.proxyMode, created.id, {
14931
- ...target,
14932
- level: "city",
14933
- city,
14934
- proxyName: proxyName(target.country, target.state, city),
14935
- config: {
14936
- country: target.country,
14937
- state: target.state,
14938
- city
14939
- }
14940
- }, null);
14941
- }
14942
- createErrors.push(`${city}: Kernel did not return a proxy id`);
14943
- } catch (err) {
14944
- createErrors.push(`${city}: ${errorText2(err)}`);
14945
- }
14946
- }
14947
- const fallbackTarget = stateTarget(target);
14948
- const existingState = findExistingStateProxy(proxies, fallbackTarget);
14949
- if (existingState?.id) {
14950
- return resolution("location_reused", options.proxyMode, existingState.id, fallbackTarget, createErrors.join(" | "));
14951
- }
15192
+ const efg = new URL(url).searchParams.get("efg");
15193
+ if (!efg) return null;
15194
+ return JSON.parse(Buffer.from(efg, "base64").toString("utf8"));
15195
+ } catch {
15196
+ return null;
15197
+ }
15198
+ }
15199
+ function manifestDurationSec(htmlWindow) {
15200
+ const decoded = decodeEscapedValue(htmlWindow);
15201
+ const match = decoded.match(/mediaPresentationDuration="PT([\d.]+)S"/);
15202
+ return match ? Number(match[1]) : null;
15203
+ }
15204
+ function metadataContent(html, key) {
15205
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15206
+ const re = new RegExp(`<meta\\s+[^>]*(?:property|name)=["']${escaped}["'][^>]*content=["']([^"']+)["'][^>]*>`, "i");
15207
+ const m = html.match(re);
15208
+ return m ? htmlDecode(m[1]).trim() : null;
15209
+ }
15210
+ function ownerNameFromHtml(html) {
15211
+ const ogTitle = metadataContent(html, "og:title");
15212
+ const pipe = ogTitle?.split("|").map((s) => s.trim()).filter(Boolean);
15213
+ if (pipe && pipe.length > 1) return pipe[pipe.length - 1] ?? null;
15214
+ return null;
15215
+ }
15216
+ function extractFacebookOrganicVideo(html, pageUrl, sourceUrl = pageUrl, quality = "best") {
15217
+ const videoId = facebookVideoIdFromUrl(pageUrl) ?? facebookVideoIdFromUrl(sourceUrl);
15218
+ const manifestIndex = videoId ? html.indexOf(`dash_mpd_debug.mpd?v=${videoId}`) : -1;
15219
+ const searchWindow = manifestIndex >= 0 ? html.slice(Math.max(0, manifestIndex - 5e4), Math.min(html.length, manifestIndex + 5e4)) : html;
15220
+ const targetDuration = manifestDurationSec(searchWindow);
15221
+ const candidates = [];
15222
+ const seen = /* @__PURE__ */ new Set();
15223
+ const re = /browser_native_(sd|hd)_url\\?"\s*:\s*\\?"([^"<]+?)\\?"/g;
15224
+ let match;
15225
+ while ((match = re.exec(searchWindow)) !== null) {
15226
+ const url = decodeEscapedValue(match[2]);
15227
+ if (!url || seen.has(url)) continue;
15228
+ seen.add(url);
15229
+ const parsed = parseEfg(url);
15230
+ let bitrate = null;
14952
15231
  try {
14953
- const created = await kernel.proxies.create({
14954
- type: "residential",
14955
- name: fallbackTarget.proxyName,
14956
- config: fallbackTarget.config
14957
- });
14958
- if (created.id) {
14959
- return resolution("location_created", options.proxyMode, created.id, fallbackTarget, createErrors.join(" | "));
14960
- }
14961
- createErrors.push(`${fallbackTarget.state}: Kernel did not return a proxy id`);
14962
- } catch (err) {
14963
- createErrors.push(`${fallbackTarget.state}: ${errorText2(err)}`);
15232
+ const value = new URL(url).searchParams.get("bitrate");
15233
+ bitrate = value ? Number(value) : null;
15234
+ } catch {
14964
15235
  }
14965
- return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, createErrors.join(" | "));
14966
- } catch (err) {
14967
- return resolution("configured_fallback", options.proxyMode, options.configuredKernelProxyId, target, errorText2(err));
15236
+ candidates.push({
15237
+ url,
15238
+ quality: match[1],
15239
+ bitrate: Number.isFinite(bitrate) ? bitrate : null,
15240
+ durationSec: typeof parsed?.duration_s === "number" ? parsed.duration_s : null,
15241
+ assetId: typeof parsed?.xpv_asset_id === "number" ? parsed.xpv_asset_id : null,
15242
+ vencodeTag: typeof parsed?.vencode_tag === "string" ? parsed.vencode_tag : null
15243
+ });
15244
+ }
15245
+ const targetCandidates = candidates.filter((candidate) => {
15246
+ if (!targetDuration || candidate.durationSec == null) return true;
15247
+ return Math.abs(candidate.durationSec - targetDuration) <= 2;
15248
+ });
15249
+ const selectable = targetCandidates.length ? targetCandidates : candidates;
15250
+ const selected = [...selectable].filter((candidate) => quality === "best" || candidate.quality === quality).sort((a, b) => {
15251
+ const aq = a.quality === "hd" ? 1 : 0;
15252
+ const bq = b.quality === "hd" ? 1 : 0;
15253
+ if (quality === "best" && aq !== bq) return bq - aq;
15254
+ return (b.bitrate ?? 0) - (a.bitrate ?? 0);
15255
+ })[0];
15256
+ if (!selected) {
15257
+ throw new Error("No progressive Facebook video URL was found in the rendered page state");
14968
15258
  }
15259
+ return {
15260
+ sourceUrl,
15261
+ pageUrl,
15262
+ videoId,
15263
+ title: metadataContent(html, "og:title") ?? metadataContent(html, "twitter:title"),
15264
+ description: metadataContent(html, "og:description") ?? metadataContent(html, "description") ?? metadataContent(html, "twitter:description"),
15265
+ ownerName: ownerNameFromHtml(html),
15266
+ videoUrl: selected.url,
15267
+ selectedQuality: selected.quality,
15268
+ bitrate: selected.bitrate,
15269
+ durationSec: targetDuration ?? selected.durationSec,
15270
+ assetId: selected.assetId,
15271
+ vencodeTag: selected.vencodeTag,
15272
+ candidates: selectable,
15273
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString()
15274
+ };
14969
15275
  }
14970
- var import_sdk6, US_STATE_CODES, US_CITY_CENTER_ZIPS;
14971
- var init_kernel_proxy_resolver = __esm({
14972
- "src/kernel-proxy-resolver.ts"() {
15276
+ async function extractFacebookOrganicVideoFromPage(page, sourceUrl, quality = "best") {
15277
+ await page.waitForLoadState("domcontentloaded", { timeout: 15e3 }).catch(() => {
15278
+ });
15279
+ await page.waitForTimeout(1500);
15280
+ const html = await page.content();
15281
+ return extractFacebookOrganicVideo(html, page.url(), sourceUrl, quality);
15282
+ }
15283
+ var init_FacebookOrganicVideoExtractor = __esm({
15284
+ "src/extractor/FacebookOrganicVideoExtractor.ts"() {
14973
15285
  "use strict";
14974
- import_sdk6 = __toESM(require("@onkernel/sdk"), 1);
14975
- init_uule();
14976
- US_STATE_CODES = {
14977
- alabama: "AL",
14978
- alaska: "AK",
14979
- arizona: "AZ",
14980
- arkansas: "AR",
14981
- california: "CA",
14982
- colorado: "CO",
14983
- connecticut: "CT",
14984
- delaware: "DE",
14985
- florida: "FL",
14986
- georgia: "GA",
14987
- hawaii: "HI",
14988
- idaho: "ID",
14989
- illinois: "IL",
14990
- indiana: "IN",
14991
- iowa: "IA",
14992
- kansas: "KS",
14993
- kentucky: "KY",
14994
- louisiana: "LA",
14995
- maine: "ME",
14996
- maryland: "MD",
14997
- massachusetts: "MA",
14998
- michigan: "MI",
14999
- minnesota: "MN",
15000
- mississippi: "MS",
15001
- missouri: "MO",
15002
- montana: "MT",
15003
- nebraska: "NE",
15004
- nevada: "NV",
15005
- "new hampshire": "NH",
15006
- "new jersey": "NJ",
15007
- "new mexico": "NM",
15008
- "new york": "NY",
15009
- "north carolina": "NC",
15010
- "north dakota": "ND",
15011
- ohio: "OH",
15012
- oklahoma: "OK",
15013
- oregon: "OR",
15014
- pennsylvania: "PA",
15015
- "rhode island": "RI",
15016
- "south carolina": "SC",
15017
- "south dakota": "SD",
15018
- tennessee: "TN",
15019
- texas: "TX",
15020
- utah: "UT",
15021
- vermont: "VT",
15022
- virginia: "VA",
15023
- washington: "WA",
15024
- "west virginia": "WV",
15025
- wisconsin: "WI",
15026
- wyoming: "WY"
15027
- };
15028
- US_CITY_CENTER_ZIPS = {
15029
- "atlanta|GA": "30303",
15030
- "austin|TX": "78701",
15031
- "baltimore|MD": "21201",
15032
- "boston|MA": "02108",
15033
- "boulder|CO": "80302",
15034
- "charlotte|NC": "28202",
15035
- "chicago|IL": "60601",
15036
- "colorado_springs|CO": "80903",
15037
- "columbus|OH": "43215",
15038
- "dallas|TX": "75201",
15039
- "denver|CO": "80202",
15040
- "detroit|MI": "48226",
15041
- "fort_collins|CO": "80524",
15042
- "fort_worth|TX": "76102",
15043
- "houston|TX": "77002",
15044
- "indianapolis|IN": "46204",
15045
- "jacksonville|FL": "32202",
15046
- "las_vegas|NV": "89101",
15047
- "los_angeles|CA": "90012",
15048
- "louisville|KY": "40202",
15049
- "loveland|CO": "80537",
15050
- "memphis|TN": "38103",
15051
- "miami|FL": "33131",
15052
- "minneapolis|MN": "55401",
15053
- "nashville|TN": "37203",
15054
- "new_york|NY": "10001",
15055
- "orlando|FL": "32801",
15056
- "philadelphia|PA": "19103",
15057
- "phoenix|AZ": "85004",
15058
- "portland|OR": "97205",
15059
- "raleigh|NC": "27601",
15060
- "richmond|VA": "23219",
15061
- "sacramento|CA": "95814",
15062
- "salt_lake_city|UT": "84101",
15063
- "san_antonio|TX": "78205",
15064
- "san_diego|CA": "92101",
15065
- "san_francisco|CA": "94103",
15066
- "san_jose|CA": "95113",
15067
- "seattle|WA": "98101"
15068
- };
15069
15286
  }
15070
15287
  });
15071
15288
 
@@ -15198,8 +15415,7 @@ async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript"
15198
15415
  const text = data.text ?? "";
15199
15416
  const chunks = data.chunks ?? [];
15200
15417
  const durationMs = Date.now() - startMs;
15201
- const audioSec = chunks.length ? chunks[chunks.length - 1]?.timestamp?.[1] ?? 0 : 0;
15202
- void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: audioSec / 60, unitType: "audio_min" });
15418
+ void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: durationMs / 1e3, unitType: "compute_sec" });
15203
15419
  return {
15204
15420
  text,
15205
15421
  chunks,
@@ -16755,6 +16971,7 @@ var init_reddit_routes = __esm({
16755
16971
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
16756
16972
  let debited = false;
16757
16973
  let refunded = false;
16974
+ let commentDebitMc = 0;
16758
16975
  try {
16759
16976
  const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD, body.url);
16760
16977
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.reddit_thread), 402);
@@ -17016,6 +17233,8 @@ var init_video_routes = __esm({
17016
17233
  init_rates();
17017
17234
  init_api_auth();
17018
17235
  init_memory();
17236
+ init_cost_telemetry();
17237
+ init_cost_context();
17019
17238
  videoApp = new import_hono9.Hono();
17020
17239
  AnalyzeBodySchema = import_zod20.z.object({
17021
17240
  sourceUrl: import_zod20.z.string().trim().url("sourceUrl must be a direct video file URL"),
@@ -17092,6 +17311,21 @@ var init_video_routes = __esm({
17092
17311
  effectiveFrames: st.frameCount ?? null
17093
17312
  };
17094
17313
  }
17314
+ if ((st.status === "done" || st.status === "failed") && typeof st.costUsd === "number" && st.costUsd > 0) {
17315
+ const llmOp = `${LedgerOperation.VIDEO_ANALYSIS_LLM}:${runId}`;
17316
+ if (!await ledgerExistsForOperation(user.id, llmOp)) {
17317
+ await runWithCostContext({ ...currentCostContext(), op: "video_analysis", userId: user.id }, async () => {
17318
+ void recordVendorUsage({ vendor: "mcp_memory_video", model: null, units: st.costUsd, unitType: "usd", error: null });
17319
+ });
17320
+ const surchargeMc = Math.round(st.costUsd * VIDEO_ANALYSIS_LLM_MARGIN_MULTIPLE * MC_PER_USD);
17321
+ if (surchargeMc > 0) {
17322
+ const { ok } = await debitMc(user.id, surchargeMc, llmOp, `real-cost-surcharge:${runId}`);
17323
+ if (!ok) {
17324
+ console.warn(`[video-routes] VIDEO_ANALYSIS_LLM surcharge debit failed for user ${user.id} (run ${runId}): insufficient balance for ${surchargeMc}mc`);
17325
+ }
17326
+ }
17327
+ }
17328
+ }
17095
17329
  return c.json({
17096
17330
  ok: true,
17097
17331
  runId,
@@ -18330,7 +18564,7 @@ var init_trustpilot_routes = __esm({
18330
18564
  let pagesFetched = 0;
18331
18565
  try {
18332
18566
  await driver.launch({
18333
- headless: true,
18567
+ headless: false,
18334
18568
  kernelApiKey: browserServiceApiKey(),
18335
18569
  viewport: { width: 1280, height: 900 },
18336
18570
  locale: "en-US"
@@ -18536,7 +18770,7 @@ var init_g2_routes = __esm({
18536
18770
  const driver = new BrowserDriver();
18537
18771
  try {
18538
18772
  await driver.launch({
18539
- headless: true,
18773
+ headless: false,
18540
18774
  kernelApiKey: browserServiceApiKey(),
18541
18775
  kernelProxyId: proxyId,
18542
18776
  viewport: { width: 1280, height: 900 },
@@ -19330,6 +19564,55 @@ ${headingSection}${kpoSection}${brandingSection}${bodySectionFull}${memSection}$
19330
19564
  }
19331
19565
  return { ...textResult, structuredContent };
19332
19566
  }
19567
+ function formatDiffPage(raw, input) {
19568
+ const parsed = parseData(raw);
19569
+ if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
19570
+ const d = parsed.data;
19571
+ const url = d.url ?? input.url;
19572
+ const title = d.title ?? "Untitled";
19573
+ const statusLine = d.status === "baseline" ? d.isReset ? "\u{1F195} **Baseline reset** \u2014 prior history discarded, this check is now the new baseline." : "\u{1F195} **Baseline captured** \u2014 first check for this URL, nothing to compare yet." : d.status === "unchanged" ? `\u2705 **No changes** since ${d.previousCheckedAt ?? "the last check"}.` : `\u{1F504} **Changed** since ${d.previousCheckedAt ?? "the last check"} \u2014 +${d.summary.linesAdded}/-${d.summary.linesRemoved} lines (${d.summary.percentChanged ?? 0}% of content).`;
19574
+ const previewHunks = d.hunks.slice(0, DIFF_PAGE_PREVIEW_HUNKS);
19575
+ const diffBlock = previewHunks.length ? [
19576
+ "\n## Diff",
19577
+ "```diff",
19578
+ ...previewHunks.flatMap((h) => h.lines.map((l) => `${h.type === "added" ? "+" : "-"} ${l}`)),
19579
+ "```",
19580
+ previewHunks.length < d.hunks.length ? `*(showing ${previewHunks.length} of ${d.hunks.length} returned hunks)*` : ""
19581
+ ].filter(Boolean).join("\n") : "";
19582
+ const truncationNotes = [
19583
+ d.contentTruncated ? "\n\u26A0\uFE0F Page content exceeded the storable cap and was truncated before hashing/diffing \u2014 changes past that point are not visible to this comparison." : "",
19584
+ d.hunksTruncatedReason ? `
19585
+ \u26A0\uFE0F ${d.hunksTruncatedReason}` : ""
19586
+ ].filter(Boolean).join("\n");
19587
+ const tips = `
19588
+ ---
19589
+ \u{1F4A1} **Tips**
19590
+ - Call \`diff_page\` again anytime to re-check \u2014 this tool is on-demand, not automatic.
19591
+ - Want the page's current content with nothing to compare? use \`extract_url\`.`;
19592
+ const full = `# Page Diff: ${url}
19593
+ **${title}**
19594
+
19595
+ ${statusLine}${diffBlock}${truncationNotes}${tips}`;
19596
+ return {
19597
+ ...oneBlock(full),
19598
+ structuredContent: {
19599
+ url,
19600
+ title: d.title,
19601
+ status: d.status,
19602
+ isReset: d.isReset,
19603
+ previousCheckedAt: d.previousCheckedAt,
19604
+ currentCheckedAt: d.currentCheckedAt,
19605
+ contentHash: d.contentHash,
19606
+ previousContentHash: d.previousContentHash,
19607
+ summary: d.summary,
19608
+ hunks: d.hunks,
19609
+ contentTruncated: d.contentTruncated,
19610
+ hunksTruncated: d.hunksTruncated,
19611
+ hunksTruncatedReason: d.hunksTruncatedReason,
19612
+ totalChangedLineCount: d.totalChangedLineCount
19613
+ }
19614
+ };
19615
+ }
19333
19616
  async function formatMapSiteUrls(raw, input, ctx) {
19334
19617
  const parsed = parseData(raw);
19335
19618
  if ("error" in parsed) return { content: [{ type: "text", text: parsed.error }], isError: true };
@@ -21054,7 +21337,7 @@ ${rows}` : ""
21054
21337
  }
21055
21338
  };
21056
21339
  }
21057
- var import_node_fs6, import_node_os6, import_node_path8, INLINE_BUDGET_BYTES, STRUCTURED_ARRAY_CAP, reportSavingEnabled, BULK_PAGE_THRESHOLD, BULK_URL_THRESHOLD;
21340
+ var import_node_fs6, import_node_os6, import_node_path8, INLINE_BUDGET_BYTES, STRUCTURED_ARRAY_CAP, reportSavingEnabled, BULK_PAGE_THRESHOLD, BULK_URL_THRESHOLD, DIFF_PAGE_PREVIEW_HUNKS;
21058
21341
  var init_mcp_response_formatter = __esm({
21059
21342
  "src/mcp/mcp-response-formatter.ts"() {
21060
21343
  "use strict";
@@ -21073,6 +21356,7 @@ var init_mcp_response_formatter = __esm({
21073
21356
  reportSavingEnabled = true;
21074
21357
  BULK_PAGE_THRESHOLD = 25;
21075
21358
  BULK_URL_THRESHOLD = 500;
21359
+ DIFF_PAGE_PREVIEW_HUNKS = 20;
21076
21360
  }
21077
21361
  });
21078
21362
 
@@ -27003,7 +27287,7 @@ var PACKAGE_VERSION;
27003
27287
  var init_version = __esm({
27004
27288
  "src/version.ts"() {
27005
27289
  "use strict";
27006
- PACKAGE_VERSION = "0.4.5";
27290
+ PACKAGE_VERSION = "0.4.12";
27007
27291
  }
27008
27292
  });
27009
27293
 
@@ -27142,7 +27426,7 @@ var init_output_schema_registry = __esm({
27142
27426
  });
27143
27427
 
27144
27428
  // src/mcp/mcp-tool-schemas.ts
27145
- var import_zod33, HarvestPaaInputSchema, ExtractUrlInputSchema, MapSiteUrlsInputSchema, ExtractSiteInputSchema, AuditSiteInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, RedditThreadInputSchema, VideoFrameAnalysisInputSchema, VideoFrameAnalysisStatusInputSchema, FacebookAdTranscribeInputSchema, FacebookVideoTranscribeInputSchema, GoogleAdsSearchInputSchema, GoogleAdsPageIntelInputSchema, GoogleAdsTranscribeInputSchema, InstagramProfileContentInputSchema, InstagramMediaDownloadInputSchema, MapsPlaceIntelInputSchema, TrustpilotReviewsInputSchema, G2ReviewsInputSchema, ReviewCardSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, ArtifactPointerOutputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchAttemptOutput, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryWorkflowOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, ExtractSiteOutputSchema, AuditSiteOutputSchema, MapsPlaceIntelOutputSchema, TrustpilotReviewsOutputSchema, G2ReviewsOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, VideoFrameAnalysisOutputSchema, VideoFrameAnalysisStatusOutputSchema, RedditThreadOutputSchema, FacebookPageIntelOutputSchema, GoogleAdsSearchOutputSchema, GoogleAdsPageIntelOutputSchema, FacebookVideoTranscribeOutputSchema, TranscriptChunkOutput, InstagramBrowserOutput, InstagramPaginationOutput, InstagramProfileContentOutputSchema, InstagramMediaTrackOutput, InstagramDownloadOutput, InstagramMediaDownloadOutputSchema, YoutubeTranscribeOutputSchema, FacebookAdTranscribeOutputSchema, GoogleAdsTranscribeOutputSchema, CaptureSerpSnapshotOutputSchema, CaptureSerpPageSnapshotsOutputSchema, CreditsInfoInputSchema, WorkflowIdSchema2, WorkflowListInputSchema, WorkflowSuggestInputSchema, WorkflowRunInputSchema, WorkflowStepInputSchema, WorkflowStatusInputSchema, WorkflowArtifactReadInputSchema, WorkflowRecipeOutput, WorkflowDefinitionOutput, WorkflowArtifactOutput, WorkflowListOutputSchema, WorkflowSuggestOutputSchema, WorkflowRunOutputSchema, WorkflowStepOutputSchema, WorkflowStatusOutputSchema, WorkflowArtifactReadOutputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema, ReportArtifactReadInputSchema, ReportArtifactReadOutputSchema;
27429
+ var import_zod33, HarvestPaaInputSchema, ExtractUrlInputSchema, DiffPageInputSchema, MapSiteUrlsInputSchema, ExtractSiteInputSchema, AuditSiteInputSchema, YoutubeHarvestInputSchema, YoutubeTranscribeInputSchema, FacebookPageIntelInputSchema, FacebookAdSearchInputSchema, RedditThreadInputSchema, VideoFrameAnalysisInputSchema, VideoFrameAnalysisStatusInputSchema, FacebookAdTranscribeInputSchema, FacebookVideoTranscribeInputSchema, GoogleAdsSearchInputSchema, GoogleAdsPageIntelInputSchema, GoogleAdsTranscribeInputSchema, InstagramProfileContentInputSchema, InstagramMediaDownloadInputSchema, MapsPlaceIntelInputSchema, TrustpilotReviewsInputSchema, G2ReviewsInputSchema, ReviewCardSchema, MapsSearchInputSchema, DirectoryWorkflowInputSchema, ArtifactPointerOutputSchema, RankTrackerModeSchema, RankTrackerBlueprintInputSchema, NullableString, MapsSearchAttemptOutput, MapsSearchOutputSchema, DirectoryMapsBusinessOutput, DirectoryWorkflowOutputSchema, RankTrackerToolPlanOutput, RankTrackerTableOutput, RankTrackerCronJobOutput, RankTrackerBlueprintOutputSchema, OrganicResultOutput, AiOverviewOutput, EntityIdsOutput, HarvestPaaOutputSchema, SearchSerpOutputSchema, ExtractUrlOutputSchema, DiffPageOutputSchema, ExtractSiteOutputSchema, AuditSiteOutputSchema, MapsPlaceIntelOutputSchema, TrustpilotReviewsOutputSchema, G2ReviewsOutputSchema, CreditsInfoOutputSchema, MapSiteUrlsOutputSchema, YoutubeHarvestOutputSchema, FacebookAdSearchOutputSchema, VideoFrameAnalysisOutputSchema, VideoFrameAnalysisStatusOutputSchema, RedditThreadOutputSchema, FacebookPageIntelOutputSchema, GoogleAdsSearchOutputSchema, GoogleAdsPageIntelOutputSchema, FacebookVideoTranscribeOutputSchema, TranscriptChunkOutput, InstagramBrowserOutput, InstagramPaginationOutput, InstagramProfileContentOutputSchema, InstagramMediaTrackOutput, InstagramDownloadOutput, InstagramMediaDownloadOutputSchema, YoutubeTranscribeOutputSchema, FacebookAdTranscribeOutputSchema, GoogleAdsTranscribeOutputSchema, CaptureSerpSnapshotOutputSchema, CaptureSerpPageSnapshotsOutputSchema, CreditsInfoInputSchema, WorkflowIdSchema2, WorkflowListInputSchema, WorkflowSuggestInputSchema, WorkflowRunInputSchema, WorkflowStepInputSchema, WorkflowStatusInputSchema, WorkflowArtifactReadInputSchema, WorkflowRecipeOutput, WorkflowDefinitionOutput, WorkflowArtifactOutput, WorkflowListOutputSchema, WorkflowSuggestOutputSchema, WorkflowRunOutputSchema, WorkflowStepOutputSchema, WorkflowStatusOutputSchema, WorkflowArtifactReadOutputSchema, SearchSerpInputSchema, CaptureSerpSnapshotInputSchema, ScreenshotInputSchema, CaptureSerpPageSnapshotsInputSchema, ReportArtifactReadInputSchema, ReportArtifactReadOutputSchema;
27146
27430
  var init_mcp_tool_schemas = __esm({
27147
27431
  "src/mcp/mcp-tool-schemas.ts"() {
27148
27432
  "use strict";
@@ -27170,6 +27454,11 @@ var init_mcp_tool_schemas = __esm({
27170
27454
  depositToVault: import_zod33.z.boolean().default(false).describe("Save the full page content into the user's MCP Memory vault server-side, embedded for semantic recall \u2014 the full body is NOT returned to chat."),
27171
27455
  vaultName: import_zod33.z.string().trim().min(1).max(120).optional().describe("Optional vault to deposit into. Defaults to the user's personal vault.")
27172
27456
  };
27457
+ DiffPageInputSchema = {
27458
+ url: import_zod33.z.string().url().describe("Public http/https URL to check for changes since the last diff_page call."),
27459
+ allowLocal: import_zod33.z.boolean().default(false).describe("Allow localhost and private-network URLs. Local development only."),
27460
+ resetBaseline: import_zod33.z.boolean().default(false).describe("Discard any previously stored snapshot for this URL and capture the current content as a fresh baseline instead of diffing against history. Use when you deliberately want to restart change tracking.")
27461
+ };
27173
27462
  MapSiteUrlsInputSchema = {
27174
27463
  url: import_zod33.z.string().url().describe("Public website URL or domain to crawl for internal URLs. Use before extract_site when the user asks to audit/map/crawl a site."),
27175
27464
  maxUrls: import_zod33.z.number().int().min(1).max(1e4).optional().describe("Maximum URLs to discover. Use 100 for normal maps, up to 10000 for a full inventory. Large maps (over 500 URLs) write the complete inventory to a local file and return only a summary plus the file path instead of the full list inline.")
@@ -27563,6 +27852,29 @@ var init_mcp_tool_schemas = __esm({
27563
27852
  error: import_zod33.z.string().optional()
27564
27853
  }).optional()
27565
27854
  };
27855
+ DiffPageOutputSchema = {
27856
+ url: import_zod33.z.string(),
27857
+ title: NullableString,
27858
+ status: import_zod33.z.enum(["baseline", "unchanged", "changed"]).describe('"baseline" = first-ever check for this URL, or resetBaseline was used \u2014 nothing to compare against. "unchanged" = content hash matched the stored snapshot. "changed" = a diff was computed.'),
27859
+ isReset: import_zod33.z.boolean().describe("True only when resetBaseline discarded a real prior snapshot \u2014 distinguishes an explicit reset from a URL's true first-ever check."),
27860
+ previousCheckedAt: NullableString.describe("ISO timestamp of the snapshot this was compared against, or null if there was none."),
27861
+ currentCheckedAt: import_zod33.z.string().describe("ISO timestamp of this check, now stored as the new snapshot."),
27862
+ contentHash: import_zod33.z.string().describe("sha256 of the full (untruncated) page content just captured."),
27863
+ previousContentHash: NullableString,
27864
+ summary: import_zod33.z.object({
27865
+ linesAdded: import_zod33.z.number().int().min(0),
27866
+ linesRemoved: import_zod33.z.number().int().min(0),
27867
+ percentChanged: import_zod33.z.number().min(0).max(100).nullable().describe('Proportion of changed lines relative to the larger of the two versions. Null when status is "baseline".')
27868
+ }),
27869
+ hunks: import_zod33.z.array(import_zod33.z.object({
27870
+ type: import_zod33.z.enum(["added", "removed"]).describe("Unchanged context lines are omitted \u2014 only added/removed lines are returned."),
27871
+ lines: import_zod33.z.array(import_zod33.z.string())
27872
+ })).describe("Ordered added/removed line hunks, capped for response size \u2014 see hunksTruncated."),
27873
+ contentTruncated: import_zod33.z.boolean().describe("True if the scraped page exceeded the 250,000-character storable cap and was truncated before hashing/diffing/storing \u2014 changes past that point are invisible to this comparison."),
27874
+ hunksTruncated: import_zod33.z.boolean().describe("True if the hunks list above was capped for response size \u2014 see hunksTruncatedReason."),
27875
+ hunksTruncatedReason: NullableString,
27876
+ totalChangedLineCount: import_zod33.z.number().int().min(0).describe("Total changed lines found before any hunksTruncated capping was applied.")
27877
+ };
27566
27878
  ExtractSiteOutputSchema = {
27567
27879
  url: import_zod33.z.string(),
27568
27880
  pageCount: import_zod33.z.number().int().min(0),
@@ -28575,6 +28887,13 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
28575
28887
  outputSchema: recordOutputSchema("extract_url", ExtractUrlOutputSchema),
28576
28888
  annotations: liveWebToolAnnotations("Single URL Extract")
28577
28889
  }, async (input) => formatExtractUrl(await executor.extractUrl(input), input));
28890
+ server.registerTool("diff_page", {
28891
+ title: "Page Change Check",
28892
+ description: "Check whether a public URL has changed since you last checked it with this tool: scrapes the current page, diffs it against your last stored snapshot for that URL, and returns what was added or removed (or confirms no change). Stores the new snapshot as the baseline for next time \u2014 on-demand only, no automatic recurring checks. Use extract_url instead when you just want the page's current content with nothing to compare against.",
28893
+ inputSchema: DiffPageInputSchema,
28894
+ outputSchema: recordOutputSchema("diff_page", DiffPageOutputSchema),
28895
+ annotations: liveWebToolAnnotations("Page Change Check")
28896
+ }, async (input) => formatDiffPage(await executor.diffPage(input), input));
28578
28897
  server.registerTool("map_site_urls", {
28579
28898
  title: "Site URL Map",
28580
28899
  description: `Map/crawl a public website for a sitemap, URL inventory, or broken-link scan. Returns internal URLs with HTTP status; ${fileBehavior("maps over 500 URLs are written to a local CSV file instead of inlined.", "large results are stored as a retrievable artifact \u2014 you get an inline summary plus an artifactId for report_artifact_read.")}`,
@@ -29006,6 +29325,9 @@ var init_http_mcp_tool_executor = __esm({
29006
29325
  extractUrl(input) {
29007
29326
  return this.call("/extract-url", input);
29008
29327
  }
29328
+ diffPage(input) {
29329
+ return this.call("/diff-page", input);
29330
+ }
29009
29331
  mapSiteUrls(input) {
29010
29332
  return this.call("/map-urls", input);
29011
29333
  }
@@ -37392,6 +37714,77 @@ var init_site_audit_worker = __esm({
37392
37714
  }
37393
37715
  });
37394
37716
 
37717
+ // src/api/page-diff.ts
37718
+ function sha256Hex(value) {
37719
+ return (0, import_node_crypto14.createHash)("sha256").update(value).digest("hex");
37720
+ }
37721
+ function truncateForStorage(value, maxChars = MAX_SNAPSHOT_CONTENT_CHARS) {
37722
+ if (value.length <= maxChars) return { value, truncated: false };
37723
+ return { value: value.slice(0, maxChars), truncated: true };
37724
+ }
37725
+ function changeLines(change) {
37726
+ const lines = change.value.split("\n");
37727
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
37728
+ return lines;
37729
+ }
37730
+ function diffPageContent(oldContent, newContent) {
37731
+ const changes = (0, import_diff.diffLines)(oldContent, newContent);
37732
+ const rawHunks = [];
37733
+ let linesAdded = 0;
37734
+ let linesRemoved = 0;
37735
+ let totalChangedLineCount = 0;
37736
+ for (const change of changes) {
37737
+ if (!change.added && !change.removed) continue;
37738
+ const lines = changeLines(change);
37739
+ totalChangedLineCount += lines.length;
37740
+ if (change.added) linesAdded += lines.length;
37741
+ if (change.removed) linesRemoved += lines.length;
37742
+ rawHunks.push({ type: change.added ? "added" : "removed", lines });
37743
+ }
37744
+ let hunksTruncated = false;
37745
+ const hunksWithinCount = rawHunks.length > MAX_DIFF_HUNKS ? rawHunks.slice(0, MAX_DIFF_HUNKS) : rawHunks;
37746
+ if (rawHunks.length > MAX_DIFF_HUNKS) hunksTruncated = true;
37747
+ const hunks = [];
37748
+ let emittedLines = 0;
37749
+ for (const hunk of hunksWithinCount) {
37750
+ if (emittedLines >= MAX_DIFF_LINES_PER_RESPONSE) {
37751
+ hunksTruncated = true;
37752
+ break;
37753
+ }
37754
+ const remaining = MAX_DIFF_LINES_PER_RESPONSE - emittedLines;
37755
+ if (hunk.lines.length > remaining) {
37756
+ hunks.push({ type: hunk.type, lines: hunk.lines.slice(0, remaining) });
37757
+ emittedLines += remaining;
37758
+ hunksTruncated = true;
37759
+ break;
37760
+ }
37761
+ hunks.push(hunk);
37762
+ emittedLines += hunk.lines.length;
37763
+ }
37764
+ const denominator = Math.max(oldContent.split("\n").length, newContent.split("\n").length, 1);
37765
+ const percentChanged = totalChangedLineCount > 0 ? Math.min(100, Math.round(totalChangedLineCount / denominator * 1e3) / 10) : 0;
37766
+ return {
37767
+ hunks,
37768
+ linesAdded,
37769
+ linesRemoved,
37770
+ percentChanged,
37771
+ hunksTruncated,
37772
+ 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,
37773
+ totalChangedLineCount
37774
+ };
37775
+ }
37776
+ var import_node_crypto14, import_diff, MAX_SNAPSHOT_CONTENT_CHARS, MAX_DIFF_HUNKS, MAX_DIFF_LINES_PER_RESPONSE;
37777
+ var init_page_diff = __esm({
37778
+ "src/api/page-diff.ts"() {
37779
+ "use strict";
37780
+ import_node_crypto14 = require("crypto");
37781
+ import_diff = require("diff");
37782
+ MAX_SNAPSHOT_CONTENT_CHARS = 25e4;
37783
+ MAX_DIFF_HUNKS = 200;
37784
+ MAX_DIFF_LINES_PER_RESPONSE = 2e3;
37785
+ }
37786
+ });
37787
+
37395
37788
  // src/api/scrape-vault-sink.ts
37396
37789
  async function depositScrapeToVault(user, opts) {
37397
37790
  try {
@@ -37826,14 +38219,17 @@ async function processJob(job) {
37826
38219
  running++;
37827
38220
  try {
37828
38221
  const opts = typeof job.options === "string" ? JSON.parse(job.options) : job.options;
37829
- const result = await harvest({
37830
- ...opts,
37831
- kernelApiKey: browserServiceApiKey(),
37832
- headless: true,
37833
- format: "json",
37834
- outputDir: "/tmp/paa-output-api",
37835
- onAttemptEvent: createHarvestAttemptRecorder(job.id, job.user_id)
37836
- });
38222
+ const result = await runWithCostContext(
38223
+ { op: opts.serpOnly ? "serp" : "paa", userId: Number(job.user_id) },
38224
+ () => harvest({
38225
+ ...opts,
38226
+ kernelApiKey: browserServiceApiKey(),
38227
+ headless: true,
38228
+ format: "json",
38229
+ outputDir: "/tmp/paa-output-api",
38230
+ onAttemptEvent: createHarvestAttemptRecorder(job.id, job.user_id)
38231
+ })
38232
+ );
37837
38233
  await completeJob(job.id, result);
37838
38234
  const attempts = await listHarvestAttempts(job.id, job.user_id);
37839
38235
  if (!opts.serpOnly && typeof opts.billingHoldMc === "number") {
@@ -37899,6 +38295,7 @@ var init_worker = __esm({
37899
38295
  init_rates();
37900
38296
  init_harvest_problems();
37901
38297
  init_harvest_attempt_events();
38298
+ init_cost_context();
37902
38299
  MAX_CONCURRENT = 2;
37903
38300
  running = 0;
37904
38301
  }
@@ -37977,9 +38374,12 @@ function opForCostPath(p) {
37977
38374
  }
37978
38375
  if (p.startsWith("/instagram/")) return "instagram";
37979
38376
  if (p === "/reddit/thread") return "reddit_thread";
38377
+ if (p.startsWith("/trustpilot/")) return "trustpilot_reviews";
38378
+ if (p.startsWith("/g2/")) return "g2_reviews";
37980
38379
  if (p.startsWith("/api/internal/site-architecture-auditor")) return "audit_site";
37981
38380
  if (p.startsWith("/api/internal/memory")) return "memory_ai";
37982
38381
  if (p.startsWith("/agent/")) return "browser_session";
38382
+ if (p.startsWith("/video/")) return "video_analysis";
37983
38383
  return null;
37984
38384
  }
37985
38385
  function maybeProvisionInBackground(user, vaultCount) {
@@ -38089,6 +38489,7 @@ var init_server = __esm({
38089
38489
  import_stripe2 = __toESM(require("stripe"), 1);
38090
38490
  init_rates();
38091
38491
  init_server_schemas();
38492
+ init_page_diff();
38092
38493
  init_scrape_vault_sink();
38093
38494
  init_scrape_blob_cleanup();
38094
38495
  init_schemas3();
@@ -38811,16 +39212,20 @@ var init_server = __esm({
38811
39212
  AbortSignal.timeout(syncTimeoutMs)
38812
39213
  ]);
38813
39214
  try {
38814
- const result = await harvest({
38815
- ...options,
38816
- kernelApiKey: browserServiceApiKey(),
38817
- headless: true,
38818
- format: "json",
38819
- outputDir: "/tmp/paa-output-api",
38820
- signal: syncSignal,
38821
- softDeadlineMs: Date.now() + Math.floor(syncTimeoutMs * 0.8),
38822
- onAttemptEvent: recordAttempt
38823
- });
39215
+ const harvestCtx = currentCostContext();
39216
+ const result = await runWithCostContext(
39217
+ { ...harvestCtx, op: options.serpOnly ? "serp" : "paa", userId: user.id },
39218
+ () => harvest({
39219
+ ...options,
39220
+ kernelApiKey: browserServiceApiKey(),
39221
+ headless: true,
39222
+ format: "json",
39223
+ outputDir: "/tmp/paa-output-api",
39224
+ signal: syncSignal,
39225
+ softDeadlineMs: Date.now() + Math.floor(syncTimeoutMs * 0.8),
39226
+ onAttemptEvent: recordAttempt
39227
+ })
39228
+ );
38824
39229
  await completeJob(jobId, result);
38825
39230
  const attempts = await listHarvestAttempts(jobId, user.id);
38826
39231
  if (!options.serpOnly) {
@@ -38988,6 +39393,91 @@ var init_server = __esm({
38988
39393
  await releaseConcurrencyGate(gate.lockId);
38989
39394
  }
38990
39395
  });
39396
+ app.post("/diff-page", auth2, async (c) => {
39397
+ const raw = await c.req.json().catch(() => ({}));
39398
+ const bodyResult = DiffPageBodySchema.safeParse(raw);
39399
+ if (!bodyResult.success) return c.json({ error: bodyResult.error.issues[0]?.message ?? "Invalid request" }, 400);
39400
+ const { url, allowLocal, resetBaseline } = bodyResult.data;
39401
+ if (!allowLocal) {
39402
+ const checked = await validatePublicHttpUrl(url, { field: "URL" });
39403
+ if (checked.error || !checked.parsed) return c.json({ error: checked.error ?? "Invalid URL" }, 400);
39404
+ } else {
39405
+ try {
39406
+ new URL(url);
39407
+ } catch {
39408
+ return c.json({ error: "Invalid URL" }, 400);
39409
+ }
39410
+ }
39411
+ const canonicalUrl = (() => {
39412
+ try {
39413
+ return new URL(url).href;
39414
+ } catch {
39415
+ return url;
39416
+ }
39417
+ })();
39418
+ const user = c.get("user");
39419
+ const gate = await acquireConcurrencyGate(user, "diff_page", {
39420
+ reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
39421
+ metadata: { url: canonicalUrl }
39422
+ });
39423
+ if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
39424
+ let debited = false;
39425
+ try {
39426
+ const { ok: dpOk, balance_mc: dpBal } = await debitMc(user.id, MC_COSTS.diff_page, LedgerOperation.DIFF_PAGE, new URL(canonicalUrl).hostname);
39427
+ if (!dpOk) return c.json(insufficientBalanceResponse(dpBal, MC_COSTS.diff_page), 402);
39428
+ debited = true;
39429
+ const existingSnapshot = await getPageSnapshot(user.id, canonicalUrl);
39430
+ const previous = resetBaseline ? null : existingSnapshot;
39431
+ const kernelApiKey = browserServiceApiKey();
39432
+ const result = await extractKpo({ url: canonicalUrl, kernelApiKey });
39433
+ const currentContent = result.bodyMarkdown ?? "";
39434
+ const currentHash = sha256Hex(currentContent);
39435
+ const { value: storedContent, truncated: contentTruncated } = truncateForStorage(currentContent);
39436
+ const currentCheckedAt = (/* @__PURE__ */ new Date()).toISOString();
39437
+ await upsertPageSnapshot(user.id, canonicalUrl, {
39438
+ contentHash: currentHash,
39439
+ content: storedContent,
39440
+ title: result.title ?? null,
39441
+ contentBytes: Buffer.byteLength(currentContent, "utf8"),
39442
+ truncated: contentTruncated
39443
+ });
39444
+ let status;
39445
+ let diff = { hunks: [], linesAdded: 0, linesRemoved: 0, percentChanged: 0, hunksTruncated: false, hunksTruncatedReason: null, totalChangedLineCount: 0 };
39446
+ if (!previous) {
39447
+ status = "baseline";
39448
+ diff.percentChanged = null;
39449
+ } else if (previous.contentHash === currentHash) {
39450
+ status = "unchanged";
39451
+ } else {
39452
+ status = "changed";
39453
+ diff = diffPageContent(previous.content, storedContent);
39454
+ }
39455
+ await logRequestEvent({ userId: user.id, source: "diff_page", status: "done", query: canonicalUrl, result: { status } });
39456
+ return c.json({
39457
+ url: canonicalUrl,
39458
+ title: result.title ?? null,
39459
+ status,
39460
+ isReset: !!resetBaseline && !!existingSnapshot,
39461
+ previousCheckedAt: previous?.checkedAt ?? null,
39462
+ currentCheckedAt,
39463
+ contentHash: currentHash,
39464
+ previousContentHash: previous?.contentHash ?? null,
39465
+ summary: { linesAdded: diff.linesAdded, linesRemoved: diff.linesRemoved, percentChanged: diff.percentChanged },
39466
+ hunks: diff.hunks,
39467
+ contentTruncated,
39468
+ hunksTruncated: diff.hunksTruncated,
39469
+ hunksTruncatedReason: diff.hunksTruncatedReason,
39470
+ totalChangedLineCount: diff.totalChangedLineCount
39471
+ });
39472
+ } catch (err) {
39473
+ const msg = err instanceof Error ? err.message : String(err);
39474
+ if (debited) await creditMc(user.id, MC_COSTS.diff_page, LedgerOperation.DIFF_PAGE, "failed call");
39475
+ await logRequestEvent({ userId: user.id, source: "diff_page", status: "failed", query: canonicalUrl, error: msg });
39476
+ return c.json({ error: msg }, 500);
39477
+ } finally {
39478
+ await releaseConcurrencyGate(gate.lockId);
39479
+ }
39480
+ });
38991
39481
  app.post("/map-urls", auth2, async (c) => {
38992
39482
  const raw = await c.req.json().catch(() => ({}));
38993
39483
  const bodyResult = MapUrlsBodySchema.safeParse(raw);