mcp-scraper 0.38.1 → 0.38.2

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 (58) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/api-server.cjs +718 -276
  3. package/dist/bin/api-server.cjs.map +1 -1
  4. package/dist/bin/api-server.js +3 -3
  5. package/dist/bin/mcp-scraper-cli.cjs +1 -1
  6. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  7. package/dist/bin/mcp-scraper-cli.js +1 -1
  8. package/dist/bin/mcp-scraper-install.cjs +1 -1
  9. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  10. package/dist/bin/mcp-scraper-install.js +1 -1
  11. package/dist/bin/mcp-stdio-server.cjs +25 -7
  12. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  13. package/dist/bin/mcp-stdio-server.js +5 -5
  14. package/dist/bin/paa-harvest.cjs +65 -4
  15. package/dist/bin/paa-harvest.cjs.map +1 -1
  16. package/dist/bin/paa-harvest.js +3 -3
  17. package/dist/{chunk-PWPUKR5U.js → chunk-AZRPG43B.js} +12 -3
  18. package/dist/chunk-AZRPG43B.js.map +1 -0
  19. package/dist/{chunk-C5UGV7RM.js → chunk-EGJKUB4Q.js} +3 -3
  20. package/dist/{chunk-C5UGV7RM.js.map → chunk-EGJKUB4Q.js.map} +1 -1
  21. package/dist/{chunk-WS2E7HA5.js → chunk-FRYT3ID4.js} +2 -2
  22. package/dist/{chunk-XKCZOPZN.js → chunk-N7KUTTCC.js} +235 -40
  23. package/dist/chunk-N7KUTTCC.js.map +1 -0
  24. package/dist/{chunk-BSD2Z5A6.js → chunk-NGM237OO.js} +66 -5
  25. package/dist/chunk-NGM237OO.js.map +1 -0
  26. package/dist/{chunk-YAW3AUTP.js → chunk-NKCCGADE.js} +23 -8
  27. package/dist/chunk-NKCCGADE.js.map +1 -0
  28. package/dist/{chunk-OLRRIA4H.js → chunk-SFRMFGQ6.js} +2 -2
  29. package/dist/{chunk-4YAFJWFG.js → chunk-YCI2PNCS.js} +3 -3
  30. package/dist/chunk-YODBNTTN.js +7 -0
  31. package/dist/chunk-YODBNTTN.js.map +1 -0
  32. package/dist/{db-JRFMBTRD.js → db-C5KVCOYT.js} +8 -4
  33. package/dist/{extract-bundle-DSFOHMAT.js → extract-bundle-KUBX6N6Z.js} +4 -4
  34. package/dist/index.cjs +65 -4
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.js +3 -3
  37. package/dist/{location-data-repository-OBJYZEGE.js → location-data-repository-TTWF3OTM.js} +3 -3
  38. package/dist/{server-2UDB6PTC.js → server-5EX6XBIA.js} +312 -151
  39. package/dist/server-5EX6XBIA.js.map +1 -0
  40. package/dist/{site-extract-repository-2YJ33J5C.js → site-extract-repository-XSPJTCIL.js} +4 -4
  41. package/dist/{worker-ESF3HCNJ.js → worker-XCPU4YSN.js} +6 -6
  42. package/docs/mcp-tool-manifest.generated.json +66 -9
  43. package/package.json +1 -1
  44. package/dist/chunk-BSD2Z5A6.js.map +0 -1
  45. package/dist/chunk-FFBPWVWC.js +0 -7
  46. package/dist/chunk-FFBPWVWC.js.map +0 -1
  47. package/dist/chunk-PWPUKR5U.js.map +0 -1
  48. package/dist/chunk-XKCZOPZN.js.map +0 -1
  49. package/dist/chunk-YAW3AUTP.js.map +0 -1
  50. package/dist/server-2UDB6PTC.js.map +0 -1
  51. /package/dist/{chunk-WS2E7HA5.js.map → chunk-FRYT3ID4.js.map} +0 -0
  52. /package/dist/{chunk-OLRRIA4H.js.map → chunk-SFRMFGQ6.js.map} +0 -0
  53. /package/dist/{chunk-4YAFJWFG.js.map → chunk-YCI2PNCS.js.map} +0 -0
  54. /package/dist/{db-JRFMBTRD.js.map → db-C5KVCOYT.js.map} +0 -0
  55. /package/dist/{extract-bundle-DSFOHMAT.js.map → extract-bundle-KUBX6N6Z.js.map} +0 -0
  56. /package/dist/{location-data-repository-OBJYZEGE.js.map → location-data-repository-TTWF3OTM.js.map} +0 -0
  57. /package/dist/{site-extract-repository-2YJ33J5C.js.map → site-extract-repository-XSPJTCIL.js.map} +0 -0
  58. /package/dist/{worker-ESF3HCNJ.js.map → worker-XCPU4YSN.js.map} +0 -0
@@ -3622,8 +3622,10 @@ __export(db_exports, {
3622
3622
  claimMonthlyFreeRefresh: () => claimMonthlyFreeRefresh,
3623
3623
  claimPendingJob: () => claimPendingJob,
3624
3624
  claimPendingKpoJob: () => claimPendingKpoJob,
3625
+ claimStripeEvent: () => claimStripeEvent,
3625
3626
  completeJob: () => completeJob,
3626
3627
  completeKpoJob: () => completeKpoJob,
3628
+ completeStripeEvent: () => completeStripeEvent,
3627
3629
  completeWorkflowRunRecord: () => completeWorkflowRunRecord,
3628
3630
  consumeCode: () => consumeCode,
3629
3631
  consumePasswordResetToken: () => consumePasswordResetToken,
@@ -3645,6 +3647,7 @@ __export(db_exports, {
3645
3647
  expireOldLots: () => expireOldLots,
3646
3648
  failJob: () => failJob,
3647
3649
  failKpoJob: () => failKpoJob,
3650
+ failStripeEvent: () => failStripeEvent,
3648
3651
  failWorkflowRunRecord: () => failWorkflowRunRecord,
3649
3652
  finishHarvestAttempt: () => finishHarvestAttempt,
3650
3653
  generateApiKey: () => generateApiKey,
@@ -3699,7 +3702,6 @@ __export(db_exports, {
3699
3702
  putCode: () => putCode,
3700
3703
  putRefresh: () => putRefresh,
3701
3704
  reconcileBalanceMc: () => reconcileBalanceMc,
3702
- recordStripeEvent: () => recordStripeEvent,
3703
3705
  recordWorkflowWebhookDelivery: () => recordWorkflowWebhookDelivery,
3704
3706
  registerClient: () => registerClient,
3705
3707
  replaceWorkflowArtifacts: () => replaceWorkflowArtifacts,
@@ -3733,6 +3735,7 @@ async function hasCurrentCoreSchema(db) {
3733
3735
  const checks = [
3734
3736
  `(SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN (${sqlStringList(CORE_SCHEMA_TABLES)})) = ${CORE_SCHEMA_TABLES.length}`,
3735
3737
  `(SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN (${sqlStringList(CORE_SCHEMA_INDEXES)})) = ${CORE_SCHEMA_INDEXES.length}`,
3738
+ `(SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name IN (${sqlStringList(CORE_SCHEMA_TRIGGERS)})) = ${CORE_SCHEMA_TRIGGERS.length}`,
3736
3739
  ...Object.entries(CORE_SCHEMA_COLUMNS).map(
3737
3740
  ([table, columns]) => `(SELECT COUNT(*) FROM pragma_table_info('${table}') WHERE name IN (${sqlStringList(columns)})) = ${columns.length}`
3738
3741
  )
@@ -4008,6 +4011,61 @@ async function migrate() {
4008
4011
  } catch {
4009
4012
  }
4010
4013
  await db.execute(`CREATE INDEX IF NOT EXISTS credit_lots_user_active ON credit_lots(user_id, expires_at, remaining_mc)`);
4014
+ await db.execute(`
4015
+ CREATE TABLE IF NOT EXISTS credit_lot_expirations (
4016
+ lot_id INTEGER PRIMARY KEY REFERENCES credit_lots(id),
4017
+ user_id INTEGER NOT NULL REFERENCES users(id),
4018
+ amount_mc INTEGER NOT NULL,
4019
+ claim_token TEXT NOT NULL UNIQUE,
4020
+ expired_at TEXT NOT NULL DEFAULT (datetime('now'))
4021
+ )
4022
+ `);
4023
+ await db.execute(`
4024
+ CREATE TRIGGER IF NOT EXISTS credit_lots_bounds_insert
4025
+ BEFORE INSERT ON credit_lots
4026
+ WHEN NEW.amount_mc < 0
4027
+ OR NEW.remaining_mc < 0
4028
+ OR NEW.remaining_mc > NEW.amount_mc
4029
+ BEGIN
4030
+ SELECT RAISE(ABORT, 'invalid credit lot bounds');
4031
+ END
4032
+ `);
4033
+ await db.execute(`
4034
+ CREATE TRIGGER IF NOT EXISTS credit_lots_bounds_update
4035
+ BEFORE UPDATE OF amount_mc, remaining_mc ON credit_lots
4036
+ WHEN NEW.amount_mc < 0
4037
+ OR NEW.remaining_mc < 0
4038
+ OR NEW.remaining_mc > NEW.amount_mc
4039
+ BEGIN
4040
+ SELECT RAISE(ABORT, 'invalid credit lot bounds');
4041
+ END
4042
+ `);
4043
+ await db.execute(`
4044
+ CREATE TRIGGER IF NOT EXISTS credit_lots_one_migration_insert
4045
+ BEFORE INSERT ON credit_lots
4046
+ WHEN NEW.source = 'migration'
4047
+ AND EXISTS (
4048
+ SELECT 1 FROM credit_lots
4049
+ WHERE user_id = NEW.user_id AND source = 'migration'
4050
+ )
4051
+ BEGIN
4052
+ SELECT RAISE(ABORT, 'duplicate migration lot');
4053
+ END
4054
+ `);
4055
+ await db.execute(`
4056
+ CREATE TRIGGER IF NOT EXISTS credit_lots_one_migration_update
4057
+ BEFORE UPDATE OF source, user_id ON credit_lots
4058
+ WHEN NEW.source = 'migration'
4059
+ AND EXISTS (
4060
+ SELECT 1 FROM credit_lots
4061
+ WHERE user_id = NEW.user_id
4062
+ AND source = 'migration'
4063
+ AND id != OLD.id
4064
+ )
4065
+ BEGIN
4066
+ SELECT RAISE(ABORT, 'duplicate migration lot');
4067
+ END
4068
+ `);
4011
4069
  await db.execute(`
4012
4070
  CREATE TABLE IF NOT EXISTS credit_lot_repairs (
4013
4071
  repair_id TEXT PRIMARY KEY,
@@ -4397,10 +4455,37 @@ async function migrate() {
4397
4455
  await db.execute(`CREATE INDEX IF NOT EXISTS workflow_webhook_deliveries_run ON workflow_webhook_deliveries(run_id)`);
4398
4456
  await db.execute(`
4399
4457
  CREATE TABLE IF NOT EXISTS stripe_events (
4400
- event_id TEXT PRIMARY KEY,
4401
- processed_at TEXT NOT NULL DEFAULT (datetime('now'))
4458
+ event_id TEXT PRIMARY KEY,
4459
+ status TEXT NOT NULL DEFAULT 'processed',
4460
+ attempt_count INTEGER NOT NULL DEFAULT 1,
4461
+ claim_token TEXT,
4462
+ last_error TEXT,
4463
+ processed_at TEXT NOT NULL DEFAULT (datetime('now')),
4464
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
4402
4465
  )
4403
4466
  `);
4467
+ for (const col of [
4468
+ `status TEXT NOT NULL DEFAULT 'processed'`,
4469
+ "attempt_count INTEGER NOT NULL DEFAULT 1",
4470
+ "claim_token TEXT",
4471
+ "last_error TEXT",
4472
+ "updated_at TEXT"
4473
+ ]) {
4474
+ try {
4475
+ await db.execute(`ALTER TABLE stripe_events ADD COLUMN ${col}`);
4476
+ } catch {
4477
+ }
4478
+ }
4479
+ await db.execute(`
4480
+ UPDATE stripe_events
4481
+ SET updated_at = COALESCE(updated_at, processed_at),
4482
+ status = COALESCE(status, 'processed'),
4483
+ attempt_count = MAX(1, COALESCE(attempt_count, 1))
4484
+ WHERE updated_at IS NULL
4485
+ OR status IS NULL
4486
+ OR attempt_count IS NULL
4487
+ OR attempt_count < 1
4488
+ `);
4404
4489
  await db.execute(`
4405
4490
  CREATE TABLE IF NOT EXISTS oauth_clients (
4406
4491
  client_id TEXT PRIMARY KEY,
@@ -4566,16 +4651,60 @@ async function rotateRefresh(oldToken, next) {
4566
4651
  args: [next.refresh_token, (/* @__PURE__ */ new Date()).toISOString(), oldToken]
4567
4652
  });
4568
4653
  }
4569
- async function recordStripeEvent(eventId) {
4570
- const res = await getDb().execute({
4571
- sql: "INSERT OR IGNORE INTO stripe_events (event_id) VALUES (?)",
4654
+ async function claimStripeEvent(eventId) {
4655
+ const token = (0, import_node_crypto.randomUUID)();
4656
+ const claimed = await getDb().execute({
4657
+ sql: `INSERT INTO stripe_events
4658
+ (event_id, status, attempt_count, claim_token, last_error, processed_at, updated_at)
4659
+ VALUES (?, 'processing', 1, ?, NULL, datetime('now'), datetime('now'))
4660
+ ON CONFLICT(event_id) DO UPDATE SET
4661
+ status = 'processing',
4662
+ attempt_count = stripe_events.attempt_count + 1,
4663
+ claim_token = excluded.claim_token,
4664
+ last_error = NULL,
4665
+ updated_at = datetime('now')
4666
+ WHERE stripe_events.status = 'failed'
4667
+ OR (
4668
+ stripe_events.status = 'processing'
4669
+ AND stripe_events.updated_at <= datetime('now', '-10 minutes')
4670
+ )`,
4671
+ args: [eventId, token]
4672
+ });
4673
+ if (claimed.rowsAffected > 0) return { state: "claimed", token };
4674
+ const current = await getDb().execute({
4675
+ sql: "SELECT status FROM stripe_events WHERE event_id = ? LIMIT 1",
4572
4676
  args: [eventId]
4573
4677
  });
4574
- return res.rowsAffected > 0;
4678
+ return { state: String(current.rows[0]?.status) === "processed" ? "processed" : "processing" };
4679
+ }
4680
+ async function completeStripeEvent(eventId, token) {
4681
+ const result = await getDb().execute({
4682
+ sql: `UPDATE stripe_events
4683
+ SET status = 'processed',
4684
+ claim_token = NULL,
4685
+ last_error = NULL,
4686
+ processed_at = datetime('now'),
4687
+ updated_at = datetime('now')
4688
+ WHERE event_id = ? AND status = 'processing' AND claim_token = ?`,
4689
+ args: [eventId, token]
4690
+ });
4691
+ return result.rowsAffected === 1;
4692
+ }
4693
+ async function failStripeEvent(eventId, token, error) {
4694
+ const result = await getDb().execute({
4695
+ sql: `UPDATE stripe_events
4696
+ SET status = 'failed',
4697
+ claim_token = NULL,
4698
+ last_error = ?,
4699
+ updated_at = datetime('now')
4700
+ WHERE event_id = ? AND status = 'processing' AND claim_token = ?`,
4701
+ args: [error.slice(0, 500), eventId, token]
4702
+ });
4703
+ return result.rowsAffected === 1;
4575
4704
  }
4576
4705
  async function stripeEventAlreadyProcessed(eventId) {
4577
4706
  const res = await getDb().execute({
4578
- sql: "SELECT 1 FROM stripe_events WHERE event_id = ? LIMIT 1",
4707
+ sql: `SELECT 1 FROM stripe_events WHERE event_id = ? AND status = 'processed' LIMIT 1`,
4579
4708
  args: [eventId]
4580
4709
  });
4581
4710
  return res.rows.length > 0;
@@ -5578,23 +5707,64 @@ async function ensureMigrated(userId) {
5578
5707
  async function expireOldLots() {
5579
5708
  const db = getDb();
5580
5709
  const lots = await db.execute({
5581
- sql: `SELECT id, user_id, remaining_mc FROM credit_lots WHERE remaining_mc > 0 AND never_expires = 0 AND expires_at <= datetime('now') LIMIT 500`
5710
+ sql: `SELECT id
5711
+ FROM credit_lots
5712
+ WHERE remaining_mc > 0
5713
+ AND never_expires = 0
5714
+ AND expires_at <= datetime('now')
5715
+ LIMIT 500`
5582
5716
  });
5583
5717
  if (lots.rows.length === 0) return { expired_lots: 0, expired_mc: 0 };
5584
- let totalMc = 0;
5585
- const users = /* @__PURE__ */ new Set();
5718
+ const claims = [];
5586
5719
  const stmts = [];
5587
5720
  for (const lot of lots.rows) {
5588
- const rem = Number(lot.remaining_mc);
5589
- const uid = Number(lot.user_id);
5590
- totalMc += rem;
5591
- users.add(uid);
5592
- stmts.push({ sql: "UPDATE credit_lots SET remaining_mc = 0 WHERE id = ?", args: [Number(lot.id)] });
5593
- stmts.push({ sql: `INSERT INTO ledger (user_id, amount_mc, operation, description) VALUES (?, ?, 'credit_expired', 'credits expired')`, args: [uid, -rem] });
5594
- }
5595
- await db.batch(stmts, "write");
5721
+ const lotId = Number(lot.id);
5722
+ const token = (0, import_node_crypto.randomUUID)();
5723
+ claims.push({ token, insertResultIndex: stmts.length });
5724
+ stmts.push({
5725
+ sql: `INSERT OR IGNORE INTO credit_lot_expirations (lot_id, user_id, amount_mc, claim_token)
5726
+ SELECT id, user_id, remaining_mc, ?
5727
+ FROM credit_lots
5728
+ WHERE id = ?
5729
+ AND remaining_mc > 0
5730
+ AND never_expires = 0
5731
+ AND expires_at <= datetime('now')`,
5732
+ args: [token, lotId]
5733
+ });
5734
+ stmts.push({
5735
+ sql: `UPDATE credit_lots
5736
+ SET remaining_mc = 0
5737
+ WHERE id = ?
5738
+ AND EXISTS (
5739
+ SELECT 1 FROM credit_lot_expirations
5740
+ WHERE lot_id = ? AND claim_token = ?
5741
+ )`,
5742
+ args: [lotId, lotId, token]
5743
+ });
5744
+ stmts.push({
5745
+ sql: `INSERT INTO ledger (user_id, amount_mc, operation, description)
5746
+ SELECT user_id, -amount_mc, 'credit_expired', 'credits expired'
5747
+ FROM credit_lot_expirations
5748
+ WHERE lot_id = ? AND claim_token = ?`,
5749
+ args: [lotId, token]
5750
+ });
5751
+ }
5752
+ const results = await db.batch(stmts, "write");
5753
+ const claimedTokens = claims.filter((claim) => results[claim.insertResultIndex]?.rowsAffected === 1).map((claim) => claim.token);
5754
+ if (claimedTokens.length === 0) return { expired_lots: 0, expired_mc: 0 };
5755
+ const placeholders = claimedTokens.map(() => "?").join(", ");
5756
+ const expired = await db.execute({
5757
+ sql: `SELECT user_id, amount_mc
5758
+ FROM credit_lot_expirations
5759
+ WHERE claim_token IN (${placeholders})`,
5760
+ args: claimedTokens
5761
+ });
5762
+ const users = new Set(expired.rows.map((row) => Number(row.user_id)));
5596
5763
  for (const uid of users) await reconcileBalanceMc(uid);
5597
- return { expired_lots: lots.rows.length, expired_mc: totalMc };
5764
+ return {
5765
+ expired_lots: expired.rows.length,
5766
+ expired_mc: expired.rows.reduce((sum, row) => sum + Number(row.amount_mc), 0)
5767
+ };
5598
5768
  }
5599
5769
  async function migrateExistingBalancesToLots() {
5600
5770
  const db = getDb();
@@ -6184,29 +6354,46 @@ async function ledgerExistsForStripePI(stripePI) {
6184
6354
  }
6185
6355
  async function reconcileBalanceMc(userId, knownBalanceMc) {
6186
6356
  const db = getDb();
6187
- const res = await db.execute({
6188
- sql: `SELECT
6189
- COUNT(*) AS lot_count,
6190
- COALESCE(SUM(CASE WHEN remaining_mc > 0 AND (never_expires = 1 OR expires_at > datetime('now')) THEN remaining_mc ELSE 0 END), 0) AS balance_mc
6191
- FROM credit_lots WHERE user_id = ?`,
6192
- args: [userId]
6193
- });
6194
- let balanceMc = Number(res.rows[0]?.balance_mc ?? 0);
6195
- if (Number(res.rows[0]?.lot_count ?? 0) === 0) {
6196
- await ensureMigrated(Number(userId));
6197
- const migrated = await db.execute({
6198
- sql: `SELECT COALESCE(SUM(remaining_mc), 0) AS balance_mc FROM credit_lots WHERE user_id = ? AND remaining_mc > 0 AND (never_expires = 1 OR expires_at > datetime('now'))`,
6357
+ void knownBalanceMc;
6358
+ for (let attempt = 0; attempt < 8; attempt += 1) {
6359
+ const snapshot = await db.execute({
6360
+ sql: `SELECT
6361
+ u.balance_mc AS cached_balance_mc,
6362
+ COUNT(l.id) AS lot_count,
6363
+ COALESCE(SUM(CASE
6364
+ WHEN l.remaining_mc > 0
6365
+ AND (l.never_expires = 1 OR l.expires_at > datetime('now'))
6366
+ THEN l.remaining_mc ELSE 0 END), 0) AS projected_balance_mc
6367
+ FROM users u
6368
+ LEFT JOIN credit_lots l ON l.user_id = u.id
6369
+ WHERE u.id = ?
6370
+ GROUP BY u.id, u.balance_mc`,
6199
6371
  args: [userId]
6200
6372
  });
6201
- balanceMc = Number(migrated.rows[0]?.balance_mc ?? 0);
6202
- }
6203
- if (knownBalanceMc !== balanceMc) {
6204
- await db.execute({
6205
- sql: "UPDATE users SET balance_mc = ? WHERE id = ? AND balance_mc != ?",
6206
- args: [balanceMc, userId, balanceMc]
6373
+ const row = snapshot.rows[0];
6374
+ if (!row) return 0;
6375
+ if (Number(row.lot_count ?? 0) === 0) {
6376
+ await ensureMigrated(Number(userId));
6377
+ const nowHasLots = await db.execute({
6378
+ sql: "SELECT 1 FROM credit_lots WHERE user_id = ? LIMIT 1",
6379
+ args: [userId]
6380
+ });
6381
+ if (nowHasLots.rows.length > 0) continue;
6382
+ }
6383
+ const cachedBalanceMc = Number(row.cached_balance_mc ?? 0);
6384
+ const projectedBalanceMc = Number(row.projected_balance_mc ?? 0);
6385
+ if (cachedBalanceMc === projectedBalanceMc) return projectedBalanceMc;
6386
+ const updated = await db.execute({
6387
+ sql: "UPDATE users SET balance_mc = ? WHERE id = ? AND balance_mc = ?",
6388
+ args: [projectedBalanceMc, userId, cachedBalanceMc]
6207
6389
  });
6390
+ if (updated.rowsAffected === 1) return projectedBalanceMc;
6208
6391
  }
6209
- return balanceMc;
6392
+ const current = await db.execute({
6393
+ sql: "SELECT balance_mc FROM users WHERE id = ? LIMIT 1",
6394
+ args: [userId]
6395
+ });
6396
+ return Number(current.rows[0]?.balance_mc ?? 0);
6210
6397
  }
6211
6398
  async function listInboxMessages(userId) {
6212
6399
  const res = await getDb().execute({
@@ -6269,7 +6456,7 @@ async function listActiveUserIds() {
6269
6456
  const res = await getDb().execute("SELECT id FROM users WHERE active = 1");
6270
6457
  return res.rows.map((r) => Number(r.id));
6271
6458
  }
6272
- var import_http, import_node_crypto, import_zod, DB_URL, DB_TOKEN, _db, _rateLimitSchemaReady, CORE_SCHEMA_VERSION, CORE_SCHEMA_TABLES, CORE_SCHEMA_INDEXES, CORE_SCHEMA_COLUMNS, REQUEST_EVENT_RESULT_MAX_BYTES, CREDIT_LOT_TTL, SiteAuditJobRowSchema, SiteAuditPhaseLogRowSchema;
6459
+ var import_http, import_node_crypto, import_zod, DB_URL, DB_TOKEN, _db, _rateLimitSchemaReady, CORE_SCHEMA_VERSION, CORE_SCHEMA_TABLES, CORE_SCHEMA_INDEXES, CORE_SCHEMA_TRIGGERS, CORE_SCHEMA_COLUMNS, REQUEST_EVENT_RESULT_MAX_BYTES, CREDIT_LOT_TTL, SiteAuditJobRowSchema, SiteAuditPhaseLogRowSchema;
6273
6460
  var init_db = __esm({
6274
6461
  "src/api/db.ts"() {
6275
6462
  "use strict";
@@ -6282,7 +6469,7 @@ var init_db = __esm({
6282
6469
  DB_TOKEN = process.env.TURSO_AUTH_TOKEN;
6283
6470
  _db = null;
6284
6471
  _rateLimitSchemaReady = false;
6285
- CORE_SCHEMA_VERSION = "2026-07-28.2";
6472
+ CORE_SCHEMA_VERSION = "2026-07-28.3";
6286
6473
  CORE_SCHEMA_TABLES = [
6287
6474
  "admin_credit_adjustment_events",
6288
6475
  "admin_credit_adjustments",
@@ -6294,6 +6481,7 @@ var init_db = __esm({
6294
6481
  "browser_profile_labels",
6295
6482
  "connected_account_billing",
6296
6483
  "concurrency_locks",
6484
+ "credit_lot_expirations",
6297
6485
  "credit_lots",
6298
6486
  "credit_lot_repairs",
6299
6487
  "free_credit_refreshes",
@@ -6362,6 +6550,12 @@ var init_db = __esm({
6362
6550
  "workflow_schedules_user_status",
6363
6551
  "workflow_webhook_deliveries_run"
6364
6552
  ];
6553
+ CORE_SCHEMA_TRIGGERS = [
6554
+ "credit_lots_bounds_insert",
6555
+ "credit_lots_bounds_update",
6556
+ "credit_lots_one_migration_insert",
6557
+ "credit_lots_one_migration_update"
6558
+ ];
6365
6559
  CORE_SCHEMA_COLUMNS = {
6366
6560
  credit_lots: ["never_expires"],
6367
6561
  harvest_attempts: ["debug_json"],
@@ -6379,6 +6573,7 @@ var init_db = __esm({
6379
6573
  "next_settlement_at",
6380
6574
  "settlement_error"
6381
6575
  ],
6576
+ stripe_events: ["attempt_count", "claim_token", "last_error", "status", "updated_at"],
6382
6577
  users: [
6383
6578
  "balance_mc",
6384
6579
  "concurrency_stripe_sub_id",
@@ -6470,7 +6665,7 @@ var init_cost_rates = __esm({
6470
6665
  "use strict";
6471
6666
  KERNEL_HEADLESS_USD_PER_SEC = 166667e-10;
6472
6667
  KERNEL_HEADFUL_USD_PER_SEC = 1333336e-10;
6473
- HEADLESS_OPS = /* @__PURE__ */ new Set(["serp", "maps_search", "yt_channel", "yt_transcription", "fb_search", "fb_ad", "instagram"]);
6668
+ HEADLESS_OPS = /* @__PURE__ */ new Set(["serp", "fb_search", "fb_ad", "instagram"]);
6474
6669
  FAL_WIZPER_USD_PER_COMPUTE_SEC = 111e-5;
6475
6670
  FAL_WIZPER_WALLCLOCK_TO_COMPUTE_SEC_FACTOR = 4;
6476
6671
  DEEPINFRA_QWEN_USD_PER_1K_OUT_TOKENS = 4e-4;
@@ -8066,12 +8261,15 @@ async function spiderSite(opts) {
8066
8261
  const discovered = { url, status, linkedFrom: from, via: "fetch" };
8067
8262
  const children = [];
8068
8263
  if (html) {
8069
- for (const link of extractLinks(html, url)) {
8070
- if (baseDomain(link) !== startDomain) continue;
8071
- if (!visited.has(link)) {
8072
- visited.add(link);
8073
- children.push({ url: link, from: url });
8264
+ try {
8265
+ for (const link of extractLinks(html, url)) {
8266
+ if (baseDomain(link) !== startDomain) continue;
8267
+ if (!visited.has(link)) {
8268
+ visited.add(link);
8269
+ children.push({ url: link, from: url });
8270
+ }
8074
8271
  }
8272
+ } catch {
8075
8273
  }
8076
8274
  }
8077
8275
  return { discovered, children };
@@ -10051,67 +10249,78 @@ async function extractSite(opts) {
10051
10249
  let truncated = false;
10052
10250
  let spider;
10053
10251
  let sitemapUrls = [];
10054
- if (rotating) {
10055
- const browserConcurrency = Math.max(1, Math.min(opts.parallelism ?? 1, 8));
10056
- const urlsPerBrowser = opts.rotateProxyEvery ?? 10;
10057
- const followLinks = !seeded;
10058
- const seen = /* @__PURE__ */ new Set();
10059
- const frontier = [];
10060
- const enqueue = (u) => {
10061
- const n = normalizeUrl(u, opts.startUrl);
10062
- if (n && !seen.has(n)) {
10063
- seen.add(n);
10064
- frontier.push(n);
10065
- }
10066
- };
10067
- if (seeded) {
10068
- for (const u of opts.seedUrls) enqueue(u);
10069
- } else {
10070
- enqueue(opts.startUrl);
10071
- sitemapUrls = await discoverSitemapUrls2(opts.startUrl, maxPages);
10072
- for (const u of sitemapUrls) enqueue(u);
10073
- }
10074
- const waveCap = Math.max(browserConcurrency * urlsPerBrowser, 30);
10075
- let done = 0;
10076
- while (frontier.length > 0 && pages.length < maxPages) {
10077
- const capacity = maxPages - pages.length;
10078
- const wave = frontier.splice(0, Math.min(capacity, waveCap));
10079
- const fetched = await crawlWithRotation(wave, {
10080
- apiKey: opts.kernelApiKey,
10081
- concurrency: browserConcurrency,
10082
- urlsPerBrowser,
10083
- onPage: (p) => {
10084
- done++;
10085
- opts.onProgress?.({ done, discovered: seen.size, frontier: frontier.length, lastOk: p.ok ? 1 : 0, lastFail: p.ok ? 0 : 1, phase: p.phase });
10252
+ let stoppedEarly = null;
10253
+ try {
10254
+ if (rotating) {
10255
+ const browserConcurrency = Math.max(1, Math.min(opts.parallelism ?? 1, 8));
10256
+ const urlsPerBrowser = opts.rotateProxyEvery ?? 10;
10257
+ const followLinks = !seeded;
10258
+ const seen = /* @__PURE__ */ new Set();
10259
+ const frontier = [];
10260
+ const enqueue = (u) => {
10261
+ const n = normalizeUrl(u, opts.startUrl);
10262
+ if (n && !seen.has(n)) {
10263
+ seen.add(n);
10264
+ frontier.push(n);
10086
10265
  }
10087
- });
10088
- for (const r of fetched) {
10089
- const page = r.html && r.status >= 200 && r.status < 300 ? parsePageData(r.url, r.html, r.status, "browser", { headers: r.headers, responseTimeMs: r.responseTimeMs, redirectUrl: r.redirectUrl }, [successfulAttempt("browser", r.status, r.responseTimeMs ?? null)]) : emptyPageData(r.url, r.status || null, "browser", {
10090
- code: r.errorCode ?? "browser_empty_response",
10091
- message: r.error ?? "Browser fetch returned no usable response.",
10092
- attempts: [failedAttempt("browser", r.status || null, r.responseTimeMs ?? null, r.errorCode ?? "browser_empty_response", r.error ?? "Browser fetch returned no usable response.")]
10266
+ };
10267
+ if (seeded) {
10268
+ for (const u of opts.seedUrls) enqueue(u);
10269
+ } else {
10270
+ enqueue(opts.startUrl);
10271
+ sitemapUrls = await discoverSitemapUrls2(opts.startUrl, maxPages);
10272
+ for (const u of sitemapUrls) enqueue(u);
10273
+ }
10274
+ const waveCap = Math.max(browserConcurrency * urlsPerBrowser, 30);
10275
+ let done = 0;
10276
+ while (frontier.length > 0 && pages.length < maxPages) {
10277
+ const capacity = maxPages - pages.length;
10278
+ const wave = frontier.splice(0, Math.min(capacity, waveCap));
10279
+ const fetched = await crawlWithRotation(wave, {
10280
+ apiKey: opts.kernelApiKey,
10281
+ concurrency: browserConcurrency,
10282
+ urlsPerBrowser,
10283
+ onPage: (p) => {
10284
+ done++;
10285
+ opts.onProgress?.({ done, discovered: seen.size, frontier: frontier.length, lastOk: p.ok ? 1 : 0, lastFail: p.ok ? 0 : 1, phase: p.phase });
10286
+ }
10093
10287
  });
10094
- pages.push(page);
10095
- browserRetries++;
10096
- if (followLinks && isPageExtractionSuccessful(page)) {
10097
- for (const href of page.discoveryLinks ?? page.outlinks.filter((link) => link.internal).map((link) => link.href)) enqueue(href);
10288
+ for (const r of fetched) {
10289
+ const page = r.html && r.status >= 200 && r.status < 300 ? parsePageData(r.url, r.html, r.status, "browser", { headers: r.headers, responseTimeMs: r.responseTimeMs, redirectUrl: r.redirectUrl }, [successfulAttempt("browser", r.status, r.responseTimeMs ?? null)]) : emptyPageData(r.url, r.status || null, "browser", {
10290
+ code: r.errorCode ?? "browser_empty_response",
10291
+ message: r.error ?? "Browser fetch returned no usable response.",
10292
+ attempts: [failedAttempt("browser", r.status || null, r.responseTimeMs ?? null, r.errorCode ?? "browser_empty_response", r.error ?? "Browser fetch returned no usable response.")]
10293
+ });
10294
+ pages.push(page);
10295
+ browserRetries++;
10296
+ if (followLinks && isPageExtractionSuccessful(page)) {
10297
+ for (const href of page.discoveryLinks ?? page.outlinks.filter((link) => link.internal).map((link) => link.href)) enqueue(href);
10298
+ }
10098
10299
  }
10099
10300
  }
10301
+ truncated = frontier.length > 0;
10302
+ spider = makeSeedSpider(opts.startUrl, [...seen]);
10303
+ } else {
10304
+ spider = seeded ? makeSeedSpider(opts.startUrl, opts.seedUrls) : await spiderSite({ startUrl: opts.startUrl, maxUrls: maxPages, concurrency: 12, kernelApiKey: opts.kernelApiKey });
10305
+ sitemapUrls = spider.sitemapUrls;
10306
+ const urlsToExtract = spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url);
10307
+ browserRetries = spider.browserRetries;
10308
+ await runWithConcurrency(urlsToExtract, concurrency, async (url) => {
10309
+ const data = await fetchAndParse(url, opts.kernelApiKey);
10310
+ if (data.via === "browser") browserRetries++;
10311
+ pages.push(data);
10312
+ });
10313
+ truncated = spider.truncated || pages.length >= maxPages;
10100
10314
  }
10101
- truncated = frontier.length > 0;
10102
- spider = makeSeedSpider(opts.startUrl, [...seen]);
10103
- } else {
10104
- spider = seeded ? makeSeedSpider(opts.startUrl, opts.seedUrls) : await spiderSite({ startUrl: opts.startUrl, maxUrls: maxPages, concurrency: 12, kernelApiKey: opts.kernelApiKey });
10105
- sitemapUrls = spider.sitemapUrls;
10106
- const urlsToExtract = spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url);
10107
- browserRetries = spider.browserRetries;
10108
- await runWithConcurrency(urlsToExtract, concurrency, async (url) => {
10109
- const data = await fetchAndParse(url, opts.kernelApiKey);
10110
- if (data.via === "browser") browserRetries++;
10111
- pages.push(data);
10112
- });
10113
- truncated = spider.truncated || pages.length >= maxPages;
10315
+ } catch (err) {
10316
+ if (pages.length === 0) throw err;
10317
+ stoppedEarly = {
10318
+ code: "crawl_stopped_early",
10319
+ message: err instanceof Error ? err.message : String(err)
10320
+ };
10321
+ truncated = true;
10114
10322
  }
10323
+ if (!spider) spider = makeSeedSpider(opts.startUrl, pages.map((p) => p.url));
10115
10324
  const sitemapSet = sitemapUrls.length > 0 ? new Set(sitemapUrls.map((u) => normalizeUrl(u, opts.startUrl) ?? u)) : null;
10116
10325
  for (const p of pages) p.inSitemap = sitemapSet ? sitemapSet.has(normalizeUrl(p.url, opts.startUrl) ?? p.url) : null;
10117
10326
  let branding = null;
@@ -10121,10 +10330,11 @@ async function extractSite(opts) {
10121
10330
  const seoAudit = opts.formats?.includes("issues") ? buildSeoAudit(pages, opts.startUrl) : null;
10122
10331
  let imageAudit = null;
10123
10332
  if (opts.formats?.includes("images")) {
10124
- imageAudit = await auditImages(pages, { concurrency: 12, timeoutMs: 12e3 });
10333
+ imageAudit = await auditImages(pages, { concurrency: 12, timeoutMs: 12e3 }).catch(() => null);
10125
10334
  }
10126
10335
  return {
10127
10336
  startUrl: opts.startUrl,
10337
+ stoppedEarly,
10128
10338
  pages,
10129
10339
  totalPages: pages.length,
10130
10340
  spider,
@@ -10928,11 +11138,14 @@ var init_rates = __esm({
10928
11138
  url_map: 500,
10929
11139
  yt_channel: 200,
10930
11140
  yt_transcription: 200,
10931
- fb_ad: 200,
11141
+ fb_ad: 700,
10932
11142
  maps_search: 500,
10933
11143
  maps_place: 6e3,
10934
11144
  maps_review: 100,
10935
- fb_search: 600,
11145
+ fb_search: 800,
11146
+ fb_search_result: 100,
11147
+ fb_page_intel: 700,
11148
+ fb_page_intel_ad: 50,
10936
11149
  fb_transcribe: 33300,
10937
11150
  google_ads_search: 600,
10938
11151
  google_ads_intel: 200,
@@ -11311,6 +11524,12 @@ var init_rates = __esm({
11311
11524
  FB_SEARCH: "fb_search",
11312
11525
  FB_TRANSCRIBE: "fb_transcribe",
11313
11526
  FB_SEARCH_REFUND: "fb_search_refund",
11527
+ FB_SEARCH_RESULT: "fb_search_result",
11528
+ FB_SEARCH_RESULT_REFUND: "fb_search_result_refund",
11529
+ FB_PAGE_INTEL: "fb_page_intel",
11530
+ FB_PAGE_INTEL_REFUND: "fb_page_intel_refund",
11531
+ FB_PAGE_INTEL_AD: "fb_page_intel_ad",
11532
+ FB_PAGE_INTEL_AD_REFUND: "fb_page_intel_ad_refund",
11314
11533
  FB_TRANSCRIBE_REFUND: "fb_transcribe_refund",
11315
11534
  GOOGLE_ADS_SEARCH: "google_ads_search",
11316
11535
  GOOGLE_ADS_SEARCH_REFUND: "google_ads_search_refund",
@@ -16262,8 +16481,12 @@ async function formatExtractSite(raw, input, ctx) {
16262
16481
  schemaTypes: schemaTypesOf(p)
16263
16482
  })));
16264
16483
  const preview = pages.slice(0, BULK_PAGE_THRESHOLD).map(pageRow).join("\n");
16484
+ const stoppedEarly = d.stoppedEarly;
16265
16485
  const summaryHeader = [
16266
16486
  `# Site Extract: ${input.url}`,
16487
+ stoppedEarly ? `
16488
+ > **Partial crawl.** The crawl stopped before finishing, so this covers ${pages.length} page(s) rather than the whole site. You were only charged for the pages returned. Reason: ${stoppedEarly.message ?? "unknown"}
16489
+ ` : "",
16267
16490
  durationLine,
16268
16491
  `
16269
16492
  ## Pages (first ${Math.min(BULK_PAGE_THRESHOLD, pages.length)} of ${pages.length})
@@ -18805,8 +19028,10 @@ var init_BrowserDriver = __esm({
18805
19028
  return this.kernelClient ? "Google returned a CAPTCHA on this session \u2014 retrying with a fresh session." : RECAPTCHA_INSTRUCTIONS;
18806
19029
  }
18807
19030
  async navigateTo(url) {
19031
+ const arrivalBase = process.env.ADS_ARRIVAL_REDIRECT_BASE?.trim();
19032
+ const navUrl = arrivalBase ? serpArrivalUrl(url, arrivalBase) : url;
18808
19033
  try {
18809
- await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
19034
+ await this.page.goto(navUrl, { waitUntil: "domcontentloaded", timeout: 45e3 });
18810
19035
  if (/google\.[^/]+\/search\?/i.test(url) && await this.isSerpChallengeVisible()) {
18811
19036
  throw new CaptchaError(this.captchaMessage());
18812
19037
  }
@@ -19022,6 +19247,30 @@ function closeOpenBusinessPanelDialogInBrowser(expectedHeading) {
19022
19247
  close?.click();
19023
19248
  return Boolean(close);
19024
19249
  }
19250
+ function readExpandedPanelRowInBrowser(input) {
19251
+ const normalize4 = (value) => (value ?? "").replace(/\s+/g, " ").trim();
19252
+ const expectedBusiness = normalize4(input.businessName).toLowerCase();
19253
+ const visible = (element) => element.getAttribute("aria-hidden") !== "true" && (element.offsetParent !== null || element.getClientRects().length > 0);
19254
+ const readText = (element) => normalize4(element.innerText || element.textContent);
19255
+ const dialogs = Array.from(document.querySelectorAll('dialog, [role="dialog"]')).filter((dialog) => visible(dialog) && readText(dialog).toLowerCase().includes(expectedBusiness)).reverse();
19256
+ for (const dialog of dialogs) {
19257
+ const row = Array.from(
19258
+ dialog.querySelectorAll('button, a, [role="button"]')
19259
+ ).find((element) => visible(element) && readText(element).startsWith(input.prefix));
19260
+ if (!row) continue;
19261
+ const body = readText(row).slice(input.prefix.length);
19262
+ const seen = /* @__PURE__ */ new Set();
19263
+ const items = [];
19264
+ for (const part of body.split(/\s*[,\u00b7]\s*/)) {
19265
+ const value = part.trim();
19266
+ if (!value || seen.has(value)) continue;
19267
+ seen.add(value);
19268
+ items.push(value);
19269
+ }
19270
+ if (items.length > 0) return items;
19271
+ }
19272
+ return [];
19273
+ }
19025
19274
  async function clickPanelRowWithRetry(page, businessName, prefix) {
19026
19275
  const input = { businessName, prefix };
19027
19276
  if (await page.evaluate(clickBusinessPanelRowInBrowser, input)) return true;
@@ -19035,9 +19284,12 @@ async function extractExpandedList(page, businessName, rowPrefix, dialogHeading)
19035
19284
  extractOpenBusinessPanelDialogItemsInBrowser,
19036
19285
  dialogHeading
19037
19286
  );
19038
- await page.evaluate(closeOpenBusinessPanelDialogInBrowser, dialogHeading);
19039
- await page.waitForTimeout(250);
19040
- return items;
19287
+ if (items.length > 0) {
19288
+ await page.evaluate(closeOpenBusinessPanelDialogInBrowser, dialogHeading);
19289
+ await page.waitForTimeout(250);
19290
+ return items;
19291
+ }
19292
+ return page.evaluate(readExpandedPanelRowInBrowser, { businessName, prefix: rowPrefix });
19041
19293
  }
19042
19294
  async function extractExpandedGoogleBusinessPanelServices(page, businessName) {
19043
19295
  const services = await extractExpandedList(page, businessName, "Services:", "Services");
@@ -19114,7 +19366,7 @@ var init_MapsSearchExtractor = __esm({
19114
19366
  await page.waitForTimeout(LOCAL_RESULTS_WAIT_MS);
19115
19367
  if (await this.detectBlock(page)) throw new CaptchaError(RECAPTCHA_INSTRUCTIONS);
19116
19368
  const reachedFinder = await this.clickIntoLocalFinder(page);
19117
- if (!reachedFinder) throw new ExtractionError('Google "More businesses" control was not found on the organic results page');
19369
+ if (!reachedFinder) throw new MapsSearchIncompletePageError('Google "More businesses" control was not found on the organic results page');
19118
19370
  await page.waitForTimeout(LOCAL_RESULTS_WAIT_MS);
19119
19371
  if (await this.detectBlock(page)) throw new CaptchaError(RECAPTCHA_INSTRUCTIONS);
19120
19372
  const results = await this.collectResults(page, options);
@@ -20100,7 +20352,8 @@ function shouldRetryMapsSearchError(input) {
20100
20352
  if (input.attemptIndex >= input.maxAttempts - 1) return false;
20101
20353
  if (!retryableMapsSearchError(input.err, input.proxyMode)) return false;
20102
20354
  if (input.err instanceof MapsSearchIncompletePageError) {
20103
- return !input.priorAttempts.some((attempt) => attempt.outcome === "results_unconfirmed");
20355
+ const unconfirmed = input.priorAttempts.filter((attempt) => attempt.outcome === "results_unconfirmed").length;
20356
+ return unconfirmed < UNCONFIRMED_PAGE_MAX_RETRIES;
20104
20357
  }
20105
20358
  return true;
20106
20359
  }
@@ -20281,7 +20534,7 @@ async function runMapsSearchWithRotation(options) {
20281
20534
  true
20282
20535
  );
20283
20536
  }
20284
- var MAPS_SEARCH_MAX_ATTEMPTS, MAPS_SEARCH_NONE_MAX_ATTEMPTS, MAPS_SEARCH_LOCATION_PROXY_MAX_ATTEMPTS, MapsSearchRotationError;
20537
+ var UNCONFIRMED_PAGE_MAX_RETRIES, MAPS_SEARCH_MAX_ATTEMPTS, MAPS_SEARCH_NONE_MAX_ATTEMPTS, MAPS_SEARCH_LOCATION_PROXY_MAX_ATTEMPTS, MapsSearchRotationError;
20285
20538
  var init_maps_search_rotation = __esm({
20286
20539
  "src/maps-search-rotation.ts"() {
20287
20540
  "use strict";
@@ -20291,6 +20544,7 @@ var init_maps_search_rotation = __esm({
20291
20544
  init_kernel_proxy_resolver();
20292
20545
  init_backup_proxy();
20293
20546
  init_cost_context();
20547
+ UNCONFIRMED_PAGE_MAX_RETRIES = 5;
20294
20548
  MAPS_SEARCH_MAX_ATTEMPTS = 3;
20295
20549
  MAPS_SEARCH_NONE_MAX_ATTEMPTS = 7;
20296
20550
  MAPS_SEARCH_LOCATION_PROXY_MAX_ATTEMPTS = 5;
@@ -25823,6 +26077,7 @@ async function resolveYouTubeAudio(videoId) {
25823
26077
  if (!base || !secret2) return null;
25824
26078
  const controller = new AbortController();
25825
26079
  const timer = setTimeout(() => controller.abort(), RESOLVE_TIMEOUT_MS);
26080
+ const startedMs = Date.now();
25826
26081
  try {
25827
26082
  const resp = await fetch(`${base.replace(/\/+$/, "")}/resolve-audio`, {
25828
26083
  method: "POST",
@@ -25840,6 +26095,12 @@ async function resolveYouTubeAudio(videoId) {
25840
26095
  console.warn(`[audio-resolver] ${videoId}: response missing mediaUrl`);
25841
26096
  return null;
25842
26097
  }
26098
+ void recordVendorUsage({
26099
+ vendor: "frames_audio_resolve",
26100
+ model: null,
26101
+ units: (Date.now() - startedMs) / 1e3,
26102
+ unitType: "compute_sec"
26103
+ });
25843
26104
  return {
25844
26105
  mediaUrl: data.mediaUrl,
25845
26106
  durationS: Number(data.durationS) || 0,
@@ -25858,6 +26119,7 @@ var RESOLVE_TIMEOUT_MS;
25858
26119
  var init_audio_resolver = __esm({
25859
26120
  "src/youtube/audio-resolver.ts"() {
25860
26121
  "use strict";
26122
+ init_cost_telemetry();
25861
26123
  RESOLVE_TIMEOUT_MS = 24e4;
25862
26124
  }
25863
26125
  });
@@ -27430,10 +27692,20 @@ function isAllowedFacebookOrganicUrl(url) {
27430
27692
  async function transcribeFacebookVideoUrl(videoUrl, markdownTitle = "# Facebook Video Transcript") {
27431
27693
  return transcribeMediaUrl(videoUrl, markdownTitle);
27432
27694
  }
27695
+ function classifyAdLibraryPage(bodyText, finalUrl) {
27696
+ if (AUTH_WALL_URL.test(finalUrl)) return "blocked";
27697
+ if (bodyText.length < MIN_RENDERED_BODY_CHARS) return "blocked";
27698
+ return AD_LIBRARY_APP_CHROME.test(bodyText) ? "reached_app" : "blocked";
27699
+ }
27700
+ async function refundEmptyPageIntel(c, userId, subject, blocked) {
27701
+ await creditMc(userId, MC_COSTS.fb_page_intel, LedgerOperation.FB_PAGE_INTEL_REFUND, blocked ? "soft-block empty result" : "empty result");
27702
+ await logRequestEvent({ userId, source: "facebook_page_intel", status: "failed", query: subject, error: blocked ? "soft-block: empty result refunded" : "empty result refunded" });
27703
+ return blocked ? c.json({ error: "soft-block: no ads returned (refunded)" }, 503) : c.json({ error: `No ads in Meta's Ad Library match "${subject}" (refunded). This is a real empty result, not a block.` }, 404);
27704
+ }
27433
27705
  async function detectSoftBlock(driver) {
27434
27706
  const page = driver.getPage();
27435
27707
  const bodyText = await page.evaluate(() => document.body?.innerText ?? "").catch(() => "");
27436
- return bodyText.length < 200 || /Log in|log in|Create new account|You must log in/.test(bodyText);
27708
+ return classifyAdLibraryPage(bodyText, page.url()) === "blocked";
27437
27709
  }
27438
27710
  function buildPageIntelUrl(body, country) {
27439
27711
  if (body.libraryId?.trim()) return `https://www.facebook.com/ads/library/?id=${FacebookAdExtractor.resolveLibraryId(body.libraryId.trim()) ?? body.libraryId.trim()}`;
@@ -27509,7 +27781,7 @@ async function kernelLaunchOptsResidential() {
27509
27781
  }
27510
27782
  return { headless: true, kernelApiKey: browserServiceApiKey(), kernelProxyId: proxyId, viewport: { width: 1280, height: 900 }, locale: "en-US" };
27511
27783
  }
27512
- var import_hono7, import_zod21, import_client5, FacebookAdBodySchema, FacebookPageIntelBodySchema, FacebookTranscribeBodySchema, FacebookVideoTranscribeBodySchema, FacebookSearchBodySchema, FacebookMediaBodySchema, facebookAdApp, ALLOWED_MEDIA_HOSTS;
27784
+ var import_hono7, import_zod21, import_client5, FacebookAdBodySchema, FacebookPageIntelBodySchema, FacebookTranscribeBodySchema, FacebookVideoTranscribeBodySchema, FacebookSearchBodySchema, FacebookMediaBodySchema, AD_LIBRARY_APP_CHROME, AUTH_WALL_URL, MIN_RENDERED_BODY_CHARS, facebookAdApp, ALLOWED_MEDIA_HOSTS;
27513
27785
  var init_facebook_ad_routes = __esm({
27514
27786
  "src/api/facebook-ad-routes.ts"() {
27515
27787
  "use strict";
@@ -27559,6 +27831,9 @@ var init_facebook_ad_routes = __esm({
27559
27831
  url: import_zod21.z.string().trim().min(1, "url is required"),
27560
27832
  filename: import_zod21.z.string().trim().optional()
27561
27833
  });
27834
+ AD_LIBRARY_APP_CHROME = /Ad Library|Search by keyword|Branded Content/i;
27835
+ AUTH_WALL_URL = /facebook\.com\/(login|checkpoint|authentication)/i;
27836
+ MIN_RENDERED_BODY_CHARS = 200;
27562
27837
  facebookAdApp = new import_hono7.Hono();
27563
27838
  facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
27564
27839
  const raw = await c.req.json().catch(() => ({}));
@@ -27619,26 +27894,27 @@ var init_facebook_ad_routes = __esm({
27619
27894
  const driver = new BrowserDriver();
27620
27895
  let refunded = false, debited = false;
27621
27896
  try {
27622
- const { ok: fbOk, balance_mc: fbBal } = await debitMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD, subject);
27623
- if (!fbOk) return c.json(insufficientBalanceResponse(fbBal, MC_COSTS.fb_ad), 402);
27897
+ const { ok: fbOk, balance_mc: fbBal } = await debitMc(fbUser.id, MC_COSTS.fb_page_intel, LedgerOperation.FB_PAGE_INTEL, subject);
27898
+ if (!fbOk) return c.json(insufficientBalanceResponse(fbBal, MC_COSTS.fb_page_intel), 402);
27624
27899
  debited = true;
27625
27900
  await driver.launch(await kernelLaunchOptsResidential());
27626
27901
  await driver.navigateTo(listingUrl);
27627
27902
  const extractor = new FacebookAdExtractor(driver);
27628
27903
  const result = await extractor.extractPageIntel(listingUrl, maxAds);
27629
- if (result.ads.length === 0 && await detectSoftBlock(driver)) {
27630
- await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "soft-block empty result");
27904
+ if (result.ads.length === 0) {
27631
27905
  refunded = true;
27632
- await logRequestEvent({ userId: fbUser.id, source: "facebook_page_intel", status: "failed", query: subject, error: "soft-block: empty result refunded" });
27633
- return c.json({ error: "soft-block: no ads returned (refunded)" }, 503);
27906
+ return await refundEmptyPageIntel(c, fbUser.id, subject, await detectSoftBlock(driver));
27634
27907
  }
27908
+ const perAdMc = result.ads.length * MC_COSTS.fb_page_intel_ad;
27909
+ const adDebit = await debitMc(fbUser.id, perAdMc, LedgerOperation.FB_PAGE_INTEL_AD, subject);
27910
+ if (!adDebit.ok) return c.json(insufficientBalanceResponse(adDebit.balance_mc, perAdMc), 402);
27635
27911
  const match = assessPageIntelMatch(body, result);
27636
27912
  const output = { ...result, ...match };
27637
27913
  await logRequestEvent({ userId: fbUser.id, source: "facebook_page_intel", status: "done", query: subject, resultCount: result.ads.length, result: output });
27638
27914
  return c.json(output);
27639
27915
  } catch (err) {
27640
27916
  const msg = err instanceof Error ? err.message : String(err);
27641
- if (debited && !refunded) await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
27917
+ if (debited && !refunded) await creditMc(fbUser.id, MC_COSTS.fb_page_intel, LedgerOperation.FB_PAGE_INTEL_REFUND, "failed call");
27642
27918
  await logRequestEvent({ userId: fbUser.id, source: "facebook_page_intel", status: "failed", query: subject, error: msg });
27643
27919
  if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
27644
27920
  return c.json({ error: msg }, 503);
@@ -27779,10 +28055,16 @@ var init_facebook_ad_routes = __esm({
27779
28055
  debited = true;
27780
28056
  await driver.launch(await kernelLaunchOptsResidential());
27781
28057
  const page = driver.getPage();
27782
- const collated = await collectAdLibraryResults(page, searchUrl, Math.max(maxResults * 4, 40));
28058
+ let collated = await collectAdLibraryResults(page, searchUrl, Math.max(maxResults * 4, 40));
28059
+ if (collated.length === 0) {
28060
+ collated = await collectAdLibraryResults(page, searchUrl, Math.max(maxResults * 4, 40));
28061
+ }
27783
28062
  const gqlAdvertisers = advertisersFromResults(collated, maxResults);
27784
28063
  if (gqlAdvertisers.length > 0) {
27785
28064
  const results2 = gqlAdvertisers.map((a) => ({ name: a.pageName, pageName: a.pageName, pageId: a.pageId, libraryId: a.sampleLibraryId, sampleLibraryId: a.sampleLibraryId, adCount: a.adCount }));
28065
+ const gqlPerResultMc = results2.length * MC_COSTS.fb_search_result;
28066
+ const gqlResultDebit = await debitMc(fbUser.id, gqlPerResultMc, LedgerOperation.FB_SEARCH_RESULT, body.query.trim());
28067
+ if (!gqlResultDebit.ok) return c.json(insufficientBalanceResponse(gqlResultDebit.balance_mc, gqlPerResultMc), 402);
27786
28068
  const searchResult2 = { query: body.query.trim(), searchUrl, results: results2, via: "graphql" };
27787
28069
  await logRequestEvent({ userId: fbUser.id, source: "facebook_search", status: "done", query: body.query.trim(), resultCount: results2.length, result: searchResult2 });
27788
28070
  return c.json(searchResult2);
@@ -27795,7 +28077,7 @@ var init_facebook_ad_routes = __esm({
27795
28077
  await page.waitForTimeout(1e3);
27796
28078
  }
27797
28079
  const rawBodyText = await page.evaluate(() => document.body?.innerText ?? "");
27798
- if (rawBodyText.length < 200 || /Log in|You must log in|Create new account/.test(rawBodyText)) {
28080
+ if (classifyAdLibraryPage(rawBodyText, page.url()) === "blocked") {
27799
28081
  await creditMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH_REFUND, "soft-block empty result");
27800
28082
  searchRefunded = true;
27801
28083
  await logRequestEvent({ userId: fbUser.id, source: "facebook_search", status: "failed", query: body.query.trim(), error: "soft-block: empty result refunded" });
@@ -27836,8 +28118,11 @@ var init_facebook_ad_routes = __esm({
27836
28118
  await creditMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH_REFUND, "empty result");
27837
28119
  searchRefunded = true;
27838
28120
  await logRequestEvent({ userId: fbUser.id, source: "facebook_search", status: "failed", query: body.query.trim(), error: "empty result refunded" });
27839
- return c.json({ error: "no advertisers found (refunded)" }, 503);
28121
+ return c.json({ error: `No advertisers in Meta's Ad Library match "${body.query.trim()}" (refunded). This is a real empty result, not a block \u2014 try a shorter or broader query, or a brand/page name.` }, 404);
27840
28122
  }
28123
+ const perResultMc = results.length * MC_COSTS.fb_search_result;
28124
+ const resultDebit = await debitMc(fbUser.id, perResultMc, LedgerOperation.FB_SEARCH_RESULT, body.query.trim());
28125
+ if (!resultDebit.ok) return c.json(insufficientBalanceResponse(resultDebit.balance_mc, perResultMc), 402);
27841
28126
  const searchResult = { query: body.query.trim(), searchUrl, results };
27842
28127
  await logRequestEvent({ userId: fbUser.id, source: "facebook_search", status: "done", query: body.query.trim(), resultCount: results.length, result: searchResult });
27843
28128
  return c.json(searchResult);
@@ -27903,9 +28188,6 @@ var init_facebook_ad_routes = __esm({
27903
28188
  });
27904
28189
 
27905
28190
  // src/extractor/GoogleAdsExtractor.ts
27906
- function looksLikeDomain(q) {
27907
- return /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(q.trim()) && !q.includes(" ");
27908
- }
27909
28191
  var ORIGIN, GoogleAdsExtractor;
27910
28192
  var init_GoogleAdsExtractor = __esm({
27911
28193
  "src/extractor/GoogleAdsExtractor.ts"() {
@@ -27921,50 +28203,108 @@ var init_GoogleAdsExtractor = __esm({
27921
28203
  }
27922
28204
  async search(query, region, maxResults) {
27923
28205
  const q = query.trim();
27924
- const url = looksLikeDomain(q) ? `${ORIGIN}/?region=${encodeURIComponent(region)}&domain=${encodeURIComponent(q)}` : `${ORIGIN}/?region=${encodeURIComponent(region)}&query=${encodeURIComponent(q)}`;
27925
- await this.driver.navigateTo(url);
28206
+ await this.driver.navigateTo(`${ORIGIN}/?region=${encodeURIComponent(region)}`);
27926
28207
  const page = this.page();
27927
- await page.waitForTimeout(3500);
27928
- await this.autoScroll(page, 6);
27929
- const advertisers = await page.evaluate((max) => {
27930
- const map = /* @__PURE__ */ new Map();
27931
- const links = Array.from(document.querySelectorAll('a[href*="/advertiser/"]'));
27932
- for (const a of links) {
27933
- const m = a.getAttribute("href")?.match(/\/advertiser\/(AR[0-9]+)/);
27934
- if (!m) continue;
27935
- const id = m[1];
27936
- const card = a.closest("creative-preview, advertiser-preview") || a.parentElement;
27937
- const nameEl = card?.querySelector('[class*="advertiser"], [class*="name"]');
27938
- const name = (nameEl?.innerText || "").trim() || null;
27939
- const cur = map.get(id);
27940
- if (cur) cur.count++;
27941
- else map.set(id, { advertiserId: id, name, count: 1 });
27942
- }
27943
- return Array.from(map.values()).slice(0, max).map((v) => ({
27944
- advertiserId: v.advertiserId,
27945
- name: v.name,
27946
- approxAdCount: v.count
27947
- }));
27948
- }, maxResults);
28208
+ await page.waitForTimeout(2500);
28209
+ const box = await page.waitForSelector('input[type="text"], input[aria-label*="Search" i], input', { timeout: 2e4 });
28210
+ await box.click();
28211
+ await box.fill("");
28212
+ await box.type(q, { delay: 110 });
28213
+ await page.waitForSelector('[role="option"]', { timeout: 2e4 }).catch(() => null);
28214
+ await page.waitForTimeout(2200);
28215
+ const raw = await page.evaluate(() => {
28216
+ const rows = Array.from(document.querySelectorAll('[role="option"]')).map((el2) => {
28217
+ const parts = Array.from(el2.querySelectorAll("*")).filter((n) => n.children.length === 0).map((n) => (n.textContent ?? "").replace(/\s+/g, " ").trim()).filter(Boolean);
28218
+ const flat = el2.innerText.replace(/\s+/g, " ").trim();
28219
+ return { parts: parts.length ? parts : [flat], flat };
28220
+ }).filter((r) => r.flat);
28221
+ return { rows };
28222
+ });
28223
+ const domainRe = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)+$/i;
28224
+ const websiteRows = raw.rows.filter((r) => domainRe.test(r.flat));
28225
+ const advertiserRows = raw.rows.filter((r) => !domainRe.test(r.flat));
28226
+ const advertisers = advertiserRows.slice(0, maxResults).map((row) => {
28227
+ const segments = row.parts.filter((p) => !/^Verified$/i.test(p));
28228
+ const adSeg = segments.find((p) => /~\s*[\d,]+\s*ads?/i.test(p));
28229
+ const adCount = adSeg?.match(/~\s*([\d,]+)\s*ads?/i);
28230
+ const rest = segments.filter((p) => p !== adSeg);
28231
+ return {
28232
+ advertiserId: null,
28233
+ name: rest[0] ?? null,
28234
+ basedIn: rest.length > 1 ? rest[rest.length - 1] : null,
28235
+ domain: null,
28236
+ approxAdCount: adCount ? Number(adCount[1].replace(/,/g, "")) : null,
28237
+ detailUrl: null
28238
+ };
28239
+ });
28240
+ const websites = websiteRows.slice(0, maxResults).map((row) => ({
28241
+ domain: row.flat,
28242
+ detailUrl: `${ORIGIN}/?region=${encodeURIComponent(region)}&domain=${encodeURIComponent(row.flat)}`
28243
+ }));
27949
28244
  return {
27950
28245
  query: q,
27951
28246
  region,
27952
28247
  advertiserCount: advertisers.length,
27953
- advertisers: advertisers.map((a) => ({
27954
- advertiserId: a.advertiserId,
27955
- name: a.name,
27956
- domain: looksLikeDomain(q) ? q : null,
27957
- approxAdCount: a.approxAdCount,
27958
- detailUrl: `${ORIGIN}/advertiser/${a.advertiserId}?region=${region}`
27959
- }))
28248
+ advertisers,
28249
+ websiteCount: websites.length,
28250
+ websites
27960
28251
  };
27961
28252
  }
28253
+ async advertiserIdOnPage() {
28254
+ const page = this.page();
28255
+ const fromUrl = page.url().match(/\/advertiser\/(AR[0-9]+)/);
28256
+ if (fromUrl) return fromUrl[1];
28257
+ return await page.evaluate(() => {
28258
+ for (const a of Array.from(document.querySelectorAll('a[href*="/advertiser/"]'))) {
28259
+ const m = a.getAttribute("href")?.match(/\/advertiser\/(AR[0-9]+)/);
28260
+ if (m) return m[1];
28261
+ }
28262
+ return null;
28263
+ });
28264
+ }
28265
+ async resolveAdvertiserIdByDomain(domain, region) {
28266
+ await this.driver.navigateTo(`${ORIGIN}/?region=${encodeURIComponent(region)}&domain=${encodeURIComponent(domain.trim())}`);
28267
+ await this.page().waitForTimeout(3500);
28268
+ return await this.advertiserIdOnPage();
28269
+ }
28270
+ async resolveAdvertiserIdByTypeahead(term, region) {
28271
+ const page = this.page();
28272
+ await this.driver.navigateTo(`${ORIGIN}/?region=${encodeURIComponent(region)}`);
28273
+ await page.waitForTimeout(2500);
28274
+ const box = await page.waitForSelector('input[type="text"], input[aria-label*="Search" i], input', { timeout: 2e4 });
28275
+ await box.click();
28276
+ await box.fill("");
28277
+ await box.type(term.trim(), { delay: 110 });
28278
+ await page.waitForSelector('[role="option"]', { timeout: 2e4 }).catch(() => null);
28279
+ await page.waitForTimeout(2e3);
28280
+ const optionCount = (await page.$$('[role="option"]')).length;
28281
+ for (let i = 0; i < Math.min(optionCount, 3); i++) {
28282
+ const options = await page.$$('[role="option"]');
28283
+ if (!options[i]) break;
28284
+ await options[i].click().catch(() => null);
28285
+ await page.waitForTimeout(3e3);
28286
+ const found = await this.advertiserIdOnPage();
28287
+ if (found) return found;
28288
+ await this.driver.navigateTo(`${ORIGIN}/?region=${encodeURIComponent(region)}`);
28289
+ await page.waitForTimeout(1500);
28290
+ const retryBox = await page.waitForSelector('input[type="text"], input', { timeout: 15e3 }).catch(() => null);
28291
+ if (!retryBox) break;
28292
+ await retryBox.click();
28293
+ await retryBox.fill("");
28294
+ await retryBox.type(term.trim(), { delay: 110 });
28295
+ await page.waitForSelector('[role="option"]', { timeout: 15e3 }).catch(() => null);
28296
+ await page.waitForTimeout(1800);
28297
+ }
28298
+ return null;
28299
+ }
27962
28300
  async pageIntel(opts) {
27963
28301
  const { region, maxAds } = opts;
27964
28302
  let advertiserId = opts.advertiserId?.trim() || null;
27965
- if (!advertiserId && opts.domain) {
27966
- const s = await this.search(opts.domain, region, 1);
27967
- advertiserId = s.advertisers[0]?.advertiserId ?? null;
28303
+ if (!advertiserId && opts.domain?.trim()) {
28304
+ advertiserId = await this.resolveAdvertiserIdByDomain(opts.domain, region);
28305
+ }
28306
+ if (!advertiserId && opts.advertiserName?.trim()) {
28307
+ advertiserId = await this.resolveAdvertiserIdByTypeahead(opts.advertiserName, region);
27968
28308
  }
27969
28309
  if (!advertiserId) {
27970
28310
  return { advertiserId: null, advertiserName: null, domain: opts.domain ?? null, region, totalCreatives: 0, videoCount: 0, imageCount: 0, textCount: 0, ads: [] };
@@ -28125,10 +28465,11 @@ var init_google_ads_routes = __esm({
28125
28465
  });
28126
28466
  GoogleAdsPageIntelBodySchema = import_zod22.z.object({
28127
28467
  advertiserId: import_zod22.z.string().trim().optional(),
28468
+ advertiserName: import_zod22.z.string().trim().optional(),
28128
28469
  domain: import_zod22.z.string().trim().optional(),
28129
28470
  region: import_zod22.z.string().trim().toUpperCase().length(2).optional(),
28130
28471
  maxAds: import_zod22.z.number().int().min(1).max(200).optional()
28131
- }).refine((d) => !!d.advertiserId || !!d.domain, { message: "advertiserId or domain is required" });
28472
+ }).refine((d) => !!d.advertiserId || !!d.domain || !!d.advertiserName, { message: "advertiserId, advertiserName, or domain is required (advertiserName and domain both come straight from google_ads_search)" });
28132
28473
  GoogleAdsTranscribeBodySchema = import_zod22.z.object({
28133
28474
  videoUrl: import_zod22.z.string().trim().min(1, "videoUrl is required")
28134
28475
  });
@@ -28154,13 +28495,13 @@ var init_google_ads_routes = __esm({
28154
28495
  const extractor = new GoogleAdsExtractor(driver);
28155
28496
  return extractor.search(body.query.trim(), region, maxResults);
28156
28497
  });
28157
- if (result.advertisers.length === 0) {
28498
+ if (result.advertisers.length === 0 && result.websites.length === 0) {
28158
28499
  await creditMc(user.id, MC_COSTS.google_ads_search, LedgerOperation.GOOGLE_ADS_SEARCH_REFUND, "empty result");
28159
28500
  refunded = true;
28160
28501
  await logRequestEvent({ userId: user.id, source: "google_ads_search", status: "failed", query: body.query.trim(), error: "empty result refunded" });
28161
28502
  return c.json({ error: "no advertisers found (refunded)" }, 503);
28162
28503
  }
28163
- await logRequestEvent({ userId: user.id, source: "google_ads_search", status: "done", query: body.query.trim(), resultCount: result.advertisers.length, result });
28504
+ await logRequestEvent({ userId: user.id, source: "google_ads_search", status: "done", query: body.query.trim(), resultCount: result.advertisers.length + result.websites.length, result });
28164
28505
  return c.json(result);
28165
28506
  } catch (err) {
28166
28507
  const msg = err instanceof Error ? err.message : String(err);
@@ -28179,7 +28520,7 @@ var init_google_ads_routes = __esm({
28179
28520
  const body = parsed.data;
28180
28521
  const region = body.region ?? "US";
28181
28522
  const maxAds = Math.min(200, Math.max(1, body.maxAds ?? 50));
28182
- const queryLabel = body.advertiserId ?? body.domain ?? "";
28523
+ const queryLabel = body.advertiserId ?? body.domain ?? body.advertiserName ?? "";
28183
28524
  const user = c.get("user");
28184
28525
  const gate = await acquireConcurrencyGate(user, "google_ads_page_intel", { reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"), metadata: { advertiserId: body.advertiserId, domain: body.domain, region } });
28185
28526
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
@@ -28192,7 +28533,7 @@ var init_google_ads_routes = __esm({
28192
28533
  const result = await runWithCostContext({ ...currentCostContext(), op: "google_ads_intel", userId: user.id }, async () => {
28193
28534
  await driver.launch(await kernelLaunchOptsResidential2());
28194
28535
  const extractor = new GoogleAdsExtractor(driver);
28195
- return extractor.pageIntel({ advertiserId: body.advertiserId, domain: body.domain, region, maxAds });
28536
+ return extractor.pageIntel({ advertiserId: body.advertiserId, advertiserName: body.advertiserName, domain: body.domain, region, maxAds });
28196
28537
  });
28197
28538
  if (result.ads.length === 0) {
28198
28539
  await creditMc(user.id, MC_COSTS.google_ads_intel, LedgerOperation.GOOGLE_ADS_INTEL_REFUND, "empty result");
@@ -29140,6 +29481,7 @@ var init_instagram_routes = __esm({
29140
29481
  init_db();
29141
29482
  init_rates();
29142
29483
  init_concurrency_gates();
29484
+ init_cost_context();
29143
29485
  init_blob_store();
29144
29486
  init_InstagramContentExtractor();
29145
29487
  init_media_transcription();
@@ -29203,6 +29545,12 @@ var init_instagram_routes = __esm({
29203
29545
  scrollDelayMs: body.scrollDelayMs,
29204
29546
  stableScrollLimit: body.stableScrollLimit
29205
29547
  });
29548
+ if (result.items.length === 0) {
29549
+ await creditMc(user.id, MC_COSTS.instagram_profile, LedgerOperation.INSTAGRAM_PROFILE_REFUND, "empty result");
29550
+ debited = false;
29551
+ await logRequestEvent({ userId: user.id, source: "instagram_profile_content", status: "failed", query: target.profileUrl, error: "empty result refunded" });
29552
+ return c.json({ error: `No public posts were readable on ${target.profileUrl} (refunded). The profile may be private, empty, or require a logged-in session.` }, 404);
29553
+ }
29206
29554
  const response = { ...result, browser: launch.browser };
29207
29555
  await logRequestEvent({ userId: user.id, source: "instagram_profile_content", status: "done", query: target.profileUrl, resultCount: result.items.length, result: response });
29208
29556
  return c.json(response);
@@ -29326,7 +29674,10 @@ var init_instagram_routes = __esm({
29326
29674
  const tx = await debitMc(user.id, holdMc, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
29327
29675
  if (!tx.ok) return c.json(insufficientBalanceResponse(tx.balance_mc, holdMc), 402);
29328
29676
  transcriptDebited = true;
29329
- transcript = await transcribeMediaUrl(audioUrl, "# Instagram Media Transcript");
29677
+ transcript = await runWithCostContext(
29678
+ { ...currentCostContext(), op: "instagram_transcribe", userId: user.id },
29679
+ () => transcribeMediaUrl(audioUrl, "# Instagram Media Transcript")
29680
+ );
29330
29681
  const settleMc = mediaTranscribeChargeMc(transcript.durationMs / 1e3);
29331
29682
  const diff = holdMc - settleMc;
29332
29683
  if (diff > 0) await creditMc(user.id, diff, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "real-cost reconcile");
@@ -30028,6 +30379,8 @@ var init_PAAExtractor = __esm({
30028
30379
  aioShareCaptured: null
30029
30380
  };
30030
30381
  aioShareUrlEarly = null;
30382
+ collectedRows = [];
30383
+ partialContext = null;
30031
30384
  normalizeQuestion(q) {
30032
30385
  return q.toLowerCase().replace(/[^\w\s]/g, "").replace(/\s+/g, " ").trim();
30033
30386
  }
@@ -30132,11 +30485,50 @@ var init_PAAExtractor = __esm({
30132
30485
  extracted_at: (/* @__PURE__ */ new Date()).toISOString()
30133
30486
  };
30134
30487
  }
30488
+ getPartialResult() {
30489
+ const ctx = this.partialContext;
30490
+ if (!ctx || this.collectedRows.length === 0) return null;
30491
+ const flat = [...this.collectedRows];
30492
+ const stats = {
30493
+ seed: ctx.seed,
30494
+ totalQuestions: flat.length,
30495
+ maxDepthReached: flat.reduce((m, r) => Math.max(m, r.depth), 0),
30496
+ durationMs: Date.now() - ctx.startMs,
30497
+ errorCount: 0
30498
+ };
30499
+ return {
30500
+ seed: ctx.seed,
30501
+ location: ctx.location,
30502
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
30503
+ diagnostics: {
30504
+ completionStatus: "paa_found",
30505
+ problem: null,
30506
+ completeness: { ...this.completeness },
30507
+ warnings: [{
30508
+ code: "paa_partial",
30509
+ surface: "paa",
30510
+ message: `Harvest stopped before finishing; returning the ${flat.length} question(s) collected up to that point.`,
30511
+ retryable: true
30512
+ }]
30513
+ },
30514
+ totalQuestions: flat.length,
30515
+ whatPeopleSaying: [],
30516
+ tree: this.buildTree(flat, ctx.seed),
30517
+ flat,
30518
+ videos: [],
30519
+ forums: [],
30520
+ organicResults: [],
30521
+ localPack: [],
30522
+ entityIds: {},
30523
+ stats
30524
+ };
30525
+ }
30135
30526
  async runBFS(page, options, signal) {
30136
30527
  const seenKeys = /* @__PURE__ */ new Set();
30137
30528
  const seenQs = /* @__PURE__ */ new Set();
30138
30529
  const orderedQs = [];
30139
- const results = [];
30530
+ this.collectedRows = [];
30531
+ const results = this.collectedRows;
30140
30532
  const abandonedNeverReclick = /* @__PURE__ */ new Set();
30141
30533
  const clickedOnceEver = /* @__PURE__ */ new Set();
30142
30534
  const itemArgs = {
@@ -30748,6 +31140,8 @@ var init_PAAExtractor = __esm({
30748
31140
  const startMs = Date.now();
30749
31141
  this.completeness = { paaWithoutAnswer: 0, paaWithoutSource: 0, paaAnswersRecovered: 0, aioShareCaptured: null };
30750
31142
  this.aioShareUrlEarly = null;
31143
+ this.collectedRows = [];
31144
+ this.partialContext = { seed: options.query, location: options.location ?? null, startMs };
30751
31145
  const isMobile = options.device === "mobile";
30752
31146
  const config = {
30753
31147
  headless: options.headless,
@@ -31295,7 +31689,23 @@ async function extractOnce2(options, signal) {
31295
31689
  } : null);
31296
31690
  cleanup = await driver.close();
31297
31691
  }
31298
- return error ? { result: null, error, cleanup, debug } : { result, error: null, cleanup, debug };
31692
+ if (error) {
31693
+ const outcome = classifyAttemptError(error);
31694
+ const budgetExhausted = outcome === "timeout" || outcome === "request_aborted";
31695
+ let salvaged = null;
31696
+ if (budgetExhausted) {
31697
+ try {
31698
+ salvaged = typeof extractor.getPartialResult === "function" ? extractor.getPartialResult() : null;
31699
+ } catch {
31700
+ salvaged = null;
31701
+ }
31702
+ }
31703
+ if (salvaged) {
31704
+ return { result: salvaged, error: null, cleanup, debug, salvagedFrom: error };
31705
+ }
31706
+ return { result: null, error, cleanup, debug };
31707
+ }
31708
+ return { result, error: null, cleanup, debug };
31299
31709
  }
31300
31710
  async function harvest(rawOptions) {
31301
31711
  const raw = typeof rawOptions === "object" && rawOptions !== null ? rawOptions : {};
@@ -32913,6 +33323,7 @@ var init_trustpilot_routes = __esm({
32913
33323
  const driver = new BrowserDriver();
32914
33324
  const allReviews = [];
32915
33325
  let pagesFetched = 0;
33326
+ let stoppedEarly = null;
32916
33327
  try {
32917
33328
  await driver.launch({
32918
33329
  headless: false,
@@ -32930,6 +33341,12 @@ var init_trustpilot_routes = __esm({
32930
33341
  if (cards.length === 0) break;
32931
33342
  allReviews.push(...cards);
32932
33343
  }
33344
+ } catch (err) {
33345
+ if (allReviews.length === 0) throw err;
33346
+ stoppedEarly = {
33347
+ code: "review_scrape_stopped_early",
33348
+ message: err instanceof Error ? err.message : String(err)
33349
+ };
32933
33350
  } finally {
32934
33351
  await driver.close();
32935
33352
  }
@@ -32955,6 +33372,7 @@ var init_trustpilot_routes = __esm({
32955
33372
  pagesFetched,
32956
33373
  reviewCount: allReviews.length,
32957
33374
  reviews: allReviews,
33375
+ stoppedEarly,
32958
33376
  durationMs: Date.now() - startMs
32959
33377
  };
32960
33378
  await logRequestEvent({ userId: user.id, source: "trustpilot_reviews", status: "done", query: domain, resultCount: allReviews.length, result });
@@ -37500,7 +37918,7 @@ var PACKAGE_VERSION;
37500
37918
  var init_version = __esm({
37501
37919
  "src/version.ts"() {
37502
37920
  "use strict";
37503
- PACKAGE_VERSION = "0.38.1";
37921
+ PACKAGE_VERSION = "0.38.2";
37504
37922
  }
37505
37923
  });
37506
37924
 
@@ -37997,9 +38415,9 @@ var init_mcp_tool_schemas = __esm({
37997
38415
  const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
37998
38416
  const looksLikeIpv4 = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname);
37999
38417
  const looksLikeIpv6 = hostname.includes(":");
38000
- const looksLikeDomain2 = hostname.includes(".");
38418
+ const looksLikeDomain = hostname.includes(".");
38001
38419
  const looksLikeLocalhost = hostname === "localhost";
38002
- if (!looksLikeIpv4 && !looksLikeIpv6 && !looksLikeDomain2 && !looksLikeLocalhost) {
38420
+ if (!looksLikeIpv4 && !looksLikeIpv6 && !looksLikeDomain && !looksLikeLocalhost) {
38003
38421
  ctx.addIssue({ code: import_zod38.z.ZodIssueCode.custom, message: WEBSITE_URL_OR_DOMAIN_ERROR });
38004
38422
  return import_zod38.z.NEVER;
38005
38423
  }
@@ -38135,8 +38553,9 @@ var init_mcp_tool_schemas = __esm({
38135
38553
  maxResults: import_zod38.z.number().int().min(1).max(20).default(10).describe("Maximum advertisers to return. Default 10, maximum 20.")
38136
38554
  };
38137
38555
  GoogleAdsPageIntelInputSchema = {
38138
- advertiserId: import_zod38.z.string().optional().describe("Google Ads Transparency advertiser ID (starts with AR...). Use one returned by google_ads_search; do not construct one yourself."),
38139
- domain: import_zod38.z.string().optional().describe("A domain (e.g. getviktor.com) whose primary advertiser to inspect when advertiserId is unknown. One of advertiserId or domain is required."),
38556
+ domain: import_zod38.z.string().optional().describe("A website from google_ads_search.websites (e.g. getviktor.com). Preferred input: people know the website, not the advertiser's registered name. Resolves to whichever advertiser account runs ads for that domain."),
38557
+ advertiserName: import_zod38.z.string().optional().describe('An advertiser name exactly as returned in google_ads_search.advertisers[].name (e.g. "PPS Plumbing Services"). Use when the user picked an advertiser rather than a website.'),
38558
+ advertiserId: import_zod38.z.string().optional().describe("Google Ads Transparency advertiser ID (starts with AR...). Only available if you already have one from a prior page-intel result \u2014 google_ads_search does NOT return advertiser IDs. Do not construct one yourself."),
38140
38559
  region: import_zod38.z.string().length(2).default("US").describe("Two-letter region code for where the ads are shown. Default US."),
38141
38560
  maxAds: import_zod38.z.number().int().min(1).max(200).default(50).describe("Maximum creatives to inspect and hydrate. Default 50, maximum 200. Prefer 25-50 for focused scans.")
38142
38561
  };
@@ -38591,6 +39010,10 @@ var init_mcp_tool_schemas = __esm({
38591
39010
  };
38592
39011
  ExtractSiteOutputSchema = {
38593
39012
  url: import_zod38.z.string(),
39013
+ stoppedEarly: import_zod38.z.object({
39014
+ code: import_zod38.z.string(),
39015
+ message: import_zod38.z.string()
39016
+ }).nullish().describe("Present when the crawl stopped before finishing. The returned pages are everything that was captured, and billing covers only those pages - treat the result as partial, not as the whole site."),
38594
39017
  pageCount: import_zod38.z.number().int().min(0).optional().describe("Absent when background is true \u2014 the crawl has not finished yet."),
38595
39018
  pages: import_zod38.z.array(import_zod38.z.object({
38596
39019
  url: import_zod38.z.string(),
@@ -38970,9 +39393,15 @@ var init_mcp_tool_schemas = __esm({
38970
39393
  advertisers: import_zod38.z.array(import_zod38.z.object({
38971
39394
  advertiserId: NullableString,
38972
39395
  name: NullableString,
39396
+ basedIn: NullableString,
38973
39397
  domain: NullableString,
38974
39398
  approxAdCount: import_zod38.z.number().int().nullable(),
38975
39399
  detailUrl: NullableString
39400
+ })),
39401
+ websiteCount: import_zod38.z.number().int().min(0),
39402
+ websites: import_zod38.z.array(import_zod38.z.object({
39403
+ domain: import_zod38.z.string(),
39404
+ detailUrl: import_zod38.z.string()
38976
39405
  }))
38977
39406
  };
38978
39407
  GoogleAdsPageIntelOutputSchema = {
@@ -40289,14 +40718,14 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
40289
40718
  }, async (input) => formatFacebookAdTranscribe(await executor.facebookAdTranscribe(input), input));
40290
40719
  server.registerTool("google_ads_search", {
40291
40720
  title: "Google Ads Transparency Search",
40292
- description: "Search the Google Ads Transparency Center to find advertisers by domain or brand name. Returns advertisers with advertiser ID and approximate ad count, to pass to google_ads_page_intel.",
40721
+ description: "Search the Google Ads Transparency Center by brand, person, or website. Returns two lists: advertisers (name, country, approximate ad count) and websites (domains). Neither carries an advertiser ID. Pass a websites[].domain (preferred - users know the website, not the registered advertiser name) or an advertisers[].name to google_ads_page_intel to pull that advertiser's actual ads.",
40293
40722
  inputSchema: GoogleAdsSearchInputSchema,
40294
40723
  outputSchema: recordOutputSchema("google_ads_search", GoogleAdsSearchOutputSchema),
40295
40724
  annotations: liveWebToolAnnotations("Google Ads Transparency Search")
40296
40725
  }, async (input) => formatGoogleAdsSearch(await executor.googleAdsSearch(input), input));
40297
40726
  server.registerTool("google_ads_page_intel", {
40298
40727
  title: "Google Ads Advertiser Intel",
40299
- description: "Harvest an advertiser's ad creatives from the Google Ads Transparency Center: format, image URLs, and \u2014 for video ads \u2014 a YouTube video ID or direct video URL. Accepts advertiserId (from google_ads_search) or a domain.",
40728
+ description: "Harvest an advertiser's ad creatives from the Google Ads Transparency Center: format, image URLs, and \u2014 for video ads \u2014 a YouTube video ID or direct video URL. Accepts a domain or advertiserName from google_ads_search (google_ads_search does not return advertiser IDs). A domain may map to several advertiser accounts; this returns the primary one.",
40300
40729
  inputSchema: GoogleAdsPageIntelInputSchema,
40301
40730
  outputSchema: recordOutputSchema("google_ads_page_intel", GoogleAdsPageIntelOutputSchema),
40302
40731
  annotations: liveWebToolAnnotations("Google Ads Advertiser Intel")
@@ -48394,6 +48823,106 @@ async function cancelStandaloneMemorySub(memSubId, baseSubId) {
48394
48823
  console.error("[stripe] could not wind down standalone memory subscription", memSubId, err instanceof Error ? err.message : err);
48395
48824
  }
48396
48825
  }
48826
+ async function processStripeEvent(event) {
48827
+ if (event.type === "invoice.paid" || event.type === "invoice.payment_succeeded") {
48828
+ const invoice = event.data.object;
48829
+ const memLineId = invoice.lines.data.map(linePriceId).find((id) => id && id in MEMORY_PLANS);
48830
+ if (memLineId) return;
48831
+ const invoiceHasTierLine = invoice.lines.data.some((l) => {
48832
+ const id = linePriceId(l);
48833
+ return id && id in SUBSCRIPTION_TIERS;
48834
+ });
48835
+ if (shouldGrantScraperSubscriptionCredits(invoiceHasTierLine, invoice.total)) {
48836
+ const subId = invoice.subscription;
48837
+ const liveBasePlanPriceId = subId ? findBasePlanItem(await stripe.subscriptions.retrieve(subId))?.price?.id : void 0;
48838
+ const tierPriceId = liveBasePlanPriceId ?? invoice.lines.data.map(linePriceId).find((id) => id && id in SUBSCRIPTION_TIERS);
48839
+ const tier = tierPriceId ? SUBSCRIPTION_TIERS[tierPriceId] : void 0;
48840
+ if (tier) {
48841
+ const user = await resolveUser3(invoice.customer, invoice.customer_email ?? void 0);
48842
+ if (user && invoice.id && !await ledgerExistsForStripePI(invoice.id)) {
48843
+ await creditMc(user.id, tier.credits_mc, LedgerOperation.SUBSCRIPTION, `${tier.label} subscription credits`, invoice.id, { neverExpires: tier.credits_never_expire });
48844
+ await setSubscriptionTier(user.id, tier.tier, tier.concurrency, subId ?? user.subscription_id);
48845
+ if (tier.includes_memory) {
48846
+ await cancelStandaloneMemorySub(user.memory_subscription_id, subId ?? user.subscription_id);
48847
+ await setMemoryPlan(user.id, "pro", null);
48848
+ await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: "pro", subscription_tier: tier.tier }));
48849
+ }
48850
+ const credentials = await syncScheduledActionCredentials(user);
48851
+ if (!credentials.ok) console.warn("[stripe] scheduled-action credential sync failed:", credentials.error ?? "unknown error");
48852
+ }
48853
+ }
48854
+ }
48855
+ }
48856
+ if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
48857
+ const sub = event.data.object;
48858
+ const priceId = findBasePlanItem(sub)?.price?.id ?? sub.items.data[0]?.price?.id;
48859
+ if (priceId && priceId in MEMORY_PLANS) {
48860
+ const mem = MEMORY_PLANS[priceId];
48861
+ const user2 = await resolveUser3(sub.customer, sub.customer_email);
48862
+ if (user2) {
48863
+ const live = sub.status === "active" || sub.status === "trialing";
48864
+ const newMemoryPlan = live ? mem.plan : "free";
48865
+ await setMemoryPlan(user2.id, newMemoryPlan, live ? sub.id : null);
48866
+ await syncMemoryKeyPlan(user2, resolveEffectiveMemoryPlan({ ...user2, memory_plan: newMemoryPlan }));
48867
+ }
48868
+ return;
48869
+ }
48870
+ const user = await getUserByStripeCustomerId(sub.customer);
48871
+ if (!user) return;
48872
+ if (priceId && priceId in SUBSCRIPTION_TIERS) {
48873
+ const tier = SUBSCRIPTION_TIERS[priceId];
48874
+ const live = sub.status === "active" || sub.status === "trialing";
48875
+ const newTier = live ? tier.tier : null;
48876
+ await setSubscriptionTier(user.id, newTier, live ? tier.concurrency : 0, live ? sub.id : null);
48877
+ if (tier.includes_memory) {
48878
+ const newMemoryPlan = live ? "pro" : "free";
48879
+ if (live) await cancelStandaloneMemorySub(user.memory_subscription_id, sub.id);
48880
+ await setMemoryPlan(user.id, newMemoryPlan, live ? sub.id : null);
48881
+ await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: newMemoryPlan }));
48882
+ } else if (user.memory_subscription_id === sub.id) {
48883
+ await setMemoryPlan(user.id, "free", null);
48884
+ await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: "free" }));
48885
+ }
48886
+ if (live) {
48887
+ const credentials = await syncScheduledActionCredentials(user);
48888
+ if (!credentials.ok) console.warn("[stripe] scheduled-action credential sync failed:", credentials.error ?? "unknown error");
48889
+ }
48890
+ } else if (priceId === CONCURRENCY_PRICE_ID && event.type === "customer.subscription.created") {
48891
+ await setExtraConcurrencySlots(user.id, 1);
48892
+ await setConcurrencySubId(user.id, sub.id);
48893
+ }
48894
+ }
48895
+ if (event.type === "customer.subscription.deleted") {
48896
+ const sub = event.data.object;
48897
+ const priceId = findBasePlanItem(sub)?.price?.id ?? sub.items.data[0]?.price?.id;
48898
+ if (priceId && priceId in SUBSCRIPTION_TIERS) {
48899
+ const tier = SUBSCRIPTION_TIERS[priceId];
48900
+ const user2 = await getUserByStripeCustomerId(sub.customer);
48901
+ if (user2) {
48902
+ await setSubscriptionTier(user2.id, null, 0, null);
48903
+ if (tier.includes_memory || user2.memory_subscription_id === sub.id) {
48904
+ await setMemoryPlan(user2.id, "free", null);
48905
+ await syncMemoryKeyPlan(user2, resolveEffectiveMemoryPlan({ ...user2, memory_plan: "free" }));
48906
+ }
48907
+ }
48908
+ return;
48909
+ }
48910
+ if (priceId && priceId in MEMORY_PLANS || await resolveUserByMemorySubscription(sub.id)) {
48911
+ const user2 = await resolveUserByMemorySubscription(sub.id) ?? await getUserByStripeCustomerId(sub.customer);
48912
+ if (user2) {
48913
+ await setMemoryPlan(user2.id, "free", null);
48914
+ await syncMemoryKeyPlan(user2, resolveEffectiveMemoryPlan({ ...user2, memory_plan: "free" }));
48915
+ }
48916
+ return;
48917
+ }
48918
+ const user = await getUserByStripeCustomerId(sub.customer);
48919
+ if (!user) return;
48920
+ if (priceId === CONCURRENCY_PRICE_ID) {
48921
+ await setExtraConcurrencySlots(user.id, 0);
48922
+ await setConcurrencySubId(user.id, null);
48923
+ }
48924
+ }
48925
+ }
48397
48926
  function linePriceId(line) {
48398
48927
  const l = line;
48399
48928
  return l?.price?.id ?? l?.pricing?.price_details?.price ?? l?.plan?.id;
@@ -48428,107 +48957,19 @@ var init_stripe_routes = __esm({
48428
48957
  } catch {
48429
48958
  return c.json({ error: "Invalid signature" }, 400);
48430
48959
  }
48431
- const isNew = await recordStripeEvent(event.id);
48432
- if (!isNew) return c.json({ received: true });
48433
- if (event.type === "invoice.paid" || event.type === "invoice.payment_succeeded") {
48434
- const invoice = event.data.object;
48435
- const memLineId = invoice.lines.data.map(linePriceId).find((id) => id && id in MEMORY_PLANS);
48436
- if (memLineId) return c.json({ received: true });
48437
- const invoiceHasTierLine = invoice.lines.data.some((l) => {
48438
- const id = linePriceId(l);
48439
- return id && id in SUBSCRIPTION_TIERS;
48440
- });
48441
- if (shouldGrantScraperSubscriptionCredits(invoiceHasTierLine, invoice.total)) {
48442
- const subId = invoice.subscription;
48443
- const liveBasePlanPriceId = subId ? findBasePlanItem(await stripe.subscriptions.retrieve(subId))?.price?.id : void 0;
48444
- const tierPriceId = liveBasePlanPriceId ?? invoice.lines.data.map(linePriceId).find((id) => id && id in SUBSCRIPTION_TIERS);
48445
- const tier = tierPriceId ? SUBSCRIPTION_TIERS[tierPriceId] : void 0;
48446
- if (tier) {
48447
- const user = await resolveUser3(invoice.customer, invoice.customer_email ?? void 0);
48448
- if (user && invoice.id && !await ledgerExistsForStripePI(invoice.id)) {
48449
- await creditMc(user.id, tier.credits_mc, LedgerOperation.SUBSCRIPTION, `${tier.label} subscription credits`, invoice.id, { neverExpires: tier.credits_never_expire });
48450
- await setSubscriptionTier(user.id, tier.tier, tier.concurrency, subId ?? user.subscription_id);
48451
- if (tier.includes_memory) {
48452
- await cancelStandaloneMemorySub(user.memory_subscription_id, subId ?? user.subscription_id);
48453
- await setMemoryPlan(user.id, "pro", null);
48454
- await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: "pro", subscription_tier: tier.tier }));
48455
- }
48456
- const credentials = await syncScheduledActionCredentials(user);
48457
- if (!credentials.ok) console.warn("[stripe] scheduled-action credential sync failed:", credentials.error ?? "unknown error");
48458
- }
48459
- }
48460
- }
48461
- }
48462
- if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
48463
- const sub = event.data.object;
48464
- const priceId = findBasePlanItem(sub)?.price?.id ?? sub.items.data[0]?.price?.id;
48465
- if (priceId && priceId in MEMORY_PLANS) {
48466
- const mem = MEMORY_PLANS[priceId];
48467
- const user2 = await resolveUser3(sub.customer, sub.customer_email);
48468
- if (user2) {
48469
- const live = sub.status === "active" || sub.status === "trialing";
48470
- const newMemoryPlan = live ? mem.plan : "free";
48471
- await setMemoryPlan(user2.id, newMemoryPlan, live ? sub.id : null);
48472
- await syncMemoryKeyPlan(user2, resolveEffectiveMemoryPlan({ ...user2, memory_plan: newMemoryPlan }));
48473
- }
48474
- return c.json({ received: true });
48475
- }
48476
- const user = await getUserByStripeCustomerId(sub.customer);
48477
- if (!user) return c.json({ received: true });
48478
- if (priceId && priceId in SUBSCRIPTION_TIERS) {
48479
- const tier = SUBSCRIPTION_TIERS[priceId];
48480
- const live = sub.status === "active" || sub.status === "trialing";
48481
- const newTier = live ? tier.tier : null;
48482
- await setSubscriptionTier(user.id, newTier, live ? tier.concurrency : 0, live ? sub.id : null);
48483
- if (tier.includes_memory) {
48484
- const newMemoryPlan = live ? "pro" : "free";
48485
- if (live) await cancelStandaloneMemorySub(user.memory_subscription_id, sub.id);
48486
- await setMemoryPlan(user.id, newMemoryPlan, live ? sub.id : null);
48487
- await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: newMemoryPlan }));
48488
- } else if (user.memory_subscription_id === sub.id) {
48489
- await setMemoryPlan(user.id, "free", null);
48490
- await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: "free" }));
48491
- }
48492
- if (live) {
48493
- const credentials = await syncScheduledActionCredentials(user);
48494
- if (!credentials.ok) console.warn("[stripe] scheduled-action credential sync failed:", credentials.error ?? "unknown error");
48495
- }
48496
- } else if (priceId === CONCURRENCY_PRICE_ID && event.type === "customer.subscription.created") {
48497
- await setExtraConcurrencySlots(user.id, user.extra_concurrency_slots + 1);
48498
- await setConcurrencySubId(user.id, sub.id);
48499
- }
48500
- }
48501
- if (event.type === "customer.subscription.deleted") {
48502
- const sub = event.data.object;
48503
- const priceId = findBasePlanItem(sub)?.price?.id ?? sub.items.data[0]?.price?.id;
48504
- if (priceId && priceId in SUBSCRIPTION_TIERS) {
48505
- const tier = SUBSCRIPTION_TIERS[priceId];
48506
- const user2 = await getUserByStripeCustomerId(sub.customer);
48507
- if (user2) {
48508
- await setSubscriptionTier(user2.id, null, 0, null);
48509
- if (tier.includes_memory || user2.memory_subscription_id === sub.id) {
48510
- await setMemoryPlan(user2.id, "free", null);
48511
- await syncMemoryKeyPlan(user2, resolveEffectiveMemoryPlan({ ...user2, memory_plan: "free" }));
48512
- }
48513
- }
48514
- return c.json({ received: true });
48515
- }
48516
- if (priceId && priceId in MEMORY_PLANS || await resolveUserByMemorySubscription(sub.id)) {
48517
- const user2 = await resolveUserByMemorySubscription(sub.id) ?? await getUserByStripeCustomerId(sub.customer);
48518
- if (user2) {
48519
- await setMemoryPlan(user2.id, "free", null);
48520
- await syncMemoryKeyPlan(user2, resolveEffectiveMemoryPlan({ ...user2, memory_plan: "free" }));
48521
- }
48522
- return c.json({ received: true });
48523
- }
48524
- const user = await getUserByStripeCustomerId(sub.customer);
48525
- if (!user) return c.json({ received: true });
48526
- if (priceId === CONCURRENCY_PRICE_ID) {
48527
- await setExtraConcurrencySlots(user.id, Math.max(0, user.extra_concurrency_slots - 1));
48528
- await setConcurrencySubId(user.id, null);
48529
- }
48960
+ const claim = await claimStripeEvent(event.id);
48961
+ if (claim.state !== "claimed") return c.json({ received: true });
48962
+ try {
48963
+ await processStripeEvent(event);
48964
+ const completed = await completeStripeEvent(event.id, claim.token);
48965
+ if (!completed) throw new Error("Stripe event claim was superseded before completion");
48966
+ return c.json({ received: true });
48967
+ } catch (error) {
48968
+ const message = error instanceof Error ? error.message : String(error);
48969
+ await failStripeEvent(event.id, claim.token, message).catch(() => false);
48970
+ console.error("[stripe] webhook processing failed", event.id, message);
48971
+ return c.json({ error: "Webhook processing failed" }, 500);
48530
48972
  }
48531
- return c.json({ received: true });
48532
48973
  });
48533
48974
  }
48534
48975
  });
@@ -55201,6 +55642,7 @@ function opForCostPath(p) {
55201
55642
  if (p.startsWith("/facebook/")) {
55202
55643
  if (p.endsWith("transcribe")) return "fb_transcribe";
55203
55644
  if (p.endsWith("/search")) return "fb_search";
55645
+ if (p.endsWith("/page-intel")) return "fb_page_intel";
55204
55646
  return "fb_ad";
55205
55647
  }
55206
55648
  if (p.startsWith("/instagram/")) return "instagram";