mcp-scraper 0.38.0 → 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 +813 -338
  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-MA5JBAUZ.js → chunk-EGJKUB4Q.js} +3 -3
  20. package/dist/{chunk-MA5JBAUZ.js.map → chunk-EGJKUB4Q.js.map} +1 -1
  21. package/dist/{chunk-PUJFYJXB.js → chunk-FRYT3ID4.js} +2 -2
  22. package/dist/{chunk-CSCD2HNS.js → chunk-N7KUTTCC.js} +312 -101
  23. package/dist/chunk-N7KUTTCC.js.map +1 -0
  24. package/dist/{chunk-LOPKN3YL.js → chunk-NGM237OO.js} +66 -5
  25. package/dist/chunk-NGM237OO.js.map +1 -0
  26. package/dist/{chunk-P64PZUCA.js → chunk-NKCCGADE.js} +23 -8
  27. package/dist/chunk-NKCCGADE.js.map +1 -0
  28. package/dist/{chunk-FSAXLDB3.js → chunk-SFRMFGQ6.js} +2 -2
  29. package/dist/{chunk-Q35WZJJK.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-N3YECFWR.js → db-C5KVCOYT.js} +10 -4
  33. package/dist/{extract-bundle-346R6MXD.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-O2VII3ON.js → location-data-repository-TTWF3OTM.js} +3 -3
  38. package/dist/{server-3Q6XIJPF.js → server-5EX6XBIA.js} +320 -152
  39. package/dist/server-5EX6XBIA.js.map +1 -0
  40. package/dist/{site-extract-repository-I3VM6WXN.js → site-extract-repository-XSPJTCIL.js} +4 -4
  41. package/dist/{worker-TTFXPPDK.js → worker-XCPU4YSN.js} +6 -6
  42. package/docs/mcp-tool-manifest.generated.json +66 -9
  43. package/package.json +4 -2
  44. package/dist/chunk-CSCD2HNS.js.map +0 -1
  45. package/dist/chunk-H3M3O457.js +0 -7
  46. package/dist/chunk-H3M3O457.js.map +0 -1
  47. package/dist/chunk-LOPKN3YL.js.map +0 -1
  48. package/dist/chunk-P64PZUCA.js.map +0 -1
  49. package/dist/chunk-PWPUKR5U.js.map +0 -1
  50. package/dist/server-3Q6XIJPF.js.map +0 -1
  51. /package/dist/{chunk-PUJFYJXB.js.map → chunk-FRYT3ID4.js.map} +0 -0
  52. /package/dist/{chunk-FSAXLDB3.js.map → chunk-SFRMFGQ6.js.map} +0 -0
  53. /package/dist/{chunk-Q35WZJJK.js.map → chunk-YCI2PNCS.js.map} +0 -0
  54. /package/dist/{db-N3YECFWR.js.map → db-C5KVCOYT.js.map} +0 -0
  55. /package/dist/{extract-bundle-346R6MXD.js.map → extract-bundle-KUBX6N6Z.js.map} +0 -0
  56. /package/dist/{location-data-repository-O2VII3ON.js.map → location-data-repository-TTWF3OTM.js.map} +0 -0
  57. /package/dist/{site-extract-repository-I3VM6WXN.js.map → site-extract-repository-XSPJTCIL.js.map} +0 -0
  58. /package/dist/{worker-TTFXPPDK.js.map → worker-XCPU4YSN.js.map} +0 -0
@@ -3598,10 +3598,20 @@ var init_url_utils = __esm({
3598
3598
  }
3599
3599
  });
3600
3600
 
3601
+ // src/api/credit-lot-integrity.ts
3602
+ var CREDIT_LOTS_EPOCH;
3603
+ var init_credit_lot_integrity = __esm({
3604
+ "src/api/credit-lot-integrity.ts"() {
3605
+ "use strict";
3606
+ CREDIT_LOTS_EPOCH = "2026-06-26 21:58:03";
3607
+ }
3608
+ });
3609
+
3601
3610
  // src/api/db.ts
3602
3611
  var db_exports = {};
3603
3612
  __export(db_exports, {
3604
3613
  CORE_SCHEMA_VERSION: () => CORE_SCHEMA_VERSION,
3614
+ CREDIT_LOTS_EPOCH: () => CREDIT_LOTS_EPOCH,
3605
3615
  CREDIT_LOT_TTL: () => CREDIT_LOT_TTL,
3606
3616
  SiteAuditJobRowSchema: () => SiteAuditJobRowSchema,
3607
3617
  SiteAuditPhaseLogRowSchema: () => SiteAuditPhaseLogRowSchema,
@@ -3612,8 +3622,10 @@ __export(db_exports, {
3612
3622
  claimMonthlyFreeRefresh: () => claimMonthlyFreeRefresh,
3613
3623
  claimPendingJob: () => claimPendingJob,
3614
3624
  claimPendingKpoJob: () => claimPendingKpoJob,
3625
+ claimStripeEvent: () => claimStripeEvent,
3615
3626
  completeJob: () => completeJob,
3616
3627
  completeKpoJob: () => completeKpoJob,
3628
+ completeStripeEvent: () => completeStripeEvent,
3617
3629
  completeWorkflowRunRecord: () => completeWorkflowRunRecord,
3618
3630
  consumeCode: () => consumeCode,
3619
3631
  consumePasswordResetToken: () => consumePasswordResetToken,
@@ -3635,6 +3647,7 @@ __export(db_exports, {
3635
3647
  expireOldLots: () => expireOldLots,
3636
3648
  failJob: () => failJob,
3637
3649
  failKpoJob: () => failKpoJob,
3650
+ failStripeEvent: () => failStripeEvent,
3638
3651
  failWorkflowRunRecord: () => failWorkflowRunRecord,
3639
3652
  finishHarvestAttempt: () => finishHarvestAttempt,
3640
3653
  generateApiKey: () => generateApiKey,
@@ -3689,7 +3702,6 @@ __export(db_exports, {
3689
3702
  putCode: () => putCode,
3690
3703
  putRefresh: () => putRefresh,
3691
3704
  reconcileBalanceMc: () => reconcileBalanceMc,
3692
- recordStripeEvent: () => recordStripeEvent,
3693
3705
  recordWorkflowWebhookDelivery: () => recordWorkflowWebhookDelivery,
3694
3706
  registerClient: () => registerClient,
3695
3707
  replaceWorkflowArtifacts: () => replaceWorkflowArtifacts,
@@ -3723,6 +3735,7 @@ async function hasCurrentCoreSchema(db) {
3723
3735
  const checks = [
3724
3736
  `(SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN (${sqlStringList(CORE_SCHEMA_TABLES)})) = ${CORE_SCHEMA_TABLES.length}`,
3725
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}`,
3726
3739
  ...Object.entries(CORE_SCHEMA_COLUMNS).map(
3727
3740
  ([table, columns]) => `(SELECT COUNT(*) FROM pragma_table_info('${table}') WHERE name IN (${sqlStringList(columns)})) = ${columns.length}`
3728
3741
  )
@@ -3998,6 +4011,76 @@ async function migrate() {
3998
4011
  } catch {
3999
4012
  }
4000
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
+ `);
4069
+ await db.execute(`
4070
+ CREATE TABLE IF NOT EXISTS credit_lot_repairs (
4071
+ repair_id TEXT PRIMARY KEY,
4072
+ user_id INTEGER NOT NULL REFERENCES users(id),
4073
+ report_sha256 TEXT NOT NULL,
4074
+ classification TEXT NOT NULL,
4075
+ before_json TEXT NOT NULL,
4076
+ plan_json TEXT NOT NULL,
4077
+ after_json TEXT,
4078
+ status TEXT NOT NULL DEFAULT 'claimed',
4079
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
4080
+ applied_at TEXT
4081
+ )
4082
+ `);
4083
+ await db.execute(`CREATE INDEX IF NOT EXISTS credit_lot_repairs_user_applied ON credit_lot_repairs(user_id, applied_at)`);
4001
4084
  await db.execute(`
4002
4085
  CREATE TABLE IF NOT EXISTS free_credit_refreshes (
4003
4086
  user_id INTEGER NOT NULL REFERENCES users(id),
@@ -4372,10 +4455,37 @@ async function migrate() {
4372
4455
  await db.execute(`CREATE INDEX IF NOT EXISTS workflow_webhook_deliveries_run ON workflow_webhook_deliveries(run_id)`);
4373
4456
  await db.execute(`
4374
4457
  CREATE TABLE IF NOT EXISTS stripe_events (
4375
- event_id TEXT PRIMARY KEY,
4376
- 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'))
4377
4465
  )
4378
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
+ `);
4379
4489
  await db.execute(`
4380
4490
  CREATE TABLE IF NOT EXISTS oauth_clients (
4381
4491
  client_id TEXT PRIMARY KEY,
@@ -4541,16 +4651,60 @@ async function rotateRefresh(oldToken, next) {
4541
4651
  args: [next.refresh_token, (/* @__PURE__ */ new Date()).toISOString(), oldToken]
4542
4652
  });
4543
4653
  }
4544
- async function recordStripeEvent(eventId) {
4545
- const res = await getDb().execute({
4546
- 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",
4547
4676
  args: [eventId]
4548
4677
  });
4549
- 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;
4550
4704
  }
4551
4705
  async function stripeEventAlreadyProcessed(eventId) {
4552
4706
  const res = await getDb().execute({
4553
- 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`,
4554
4708
  args: [eventId]
4555
4709
  });
4556
4710
  return res.rows.length > 0;
@@ -5530,74 +5684,114 @@ async function resolveUserByMemorySubscription(subscriptionId) {
5530
5684
  }
5531
5685
  async function ensureMigrated(userId) {
5532
5686
  const db = getDb();
5533
- const hasLots = await db.execute({ sql: "SELECT 1 FROM credit_lots WHERE user_id = ? LIMIT 1", args: [userId] });
5534
- if (hasLots.rows.length) return;
5535
- const legacy = await db.execute({ sql: "SELECT COALESCE(SUM(amount_mc), 0) AS bal FROM ledger WHERE user_id = ?", args: [userId] });
5536
- const bal = Number(legacy.rows[0]?.bal ?? 0);
5537
- if (bal > 0) {
5538
- await db.execute({
5539
- sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, expires_at) VALUES (?, ?, ?, 'migration', datetime('now', ?))`,
5540
- args: [userId, bal, bal, CREDIT_LOT_TTL]
5541
- });
5542
- }
5543
- }
5544
- async function drainLots(userId, mc) {
5545
- const db = getDb();
5546
- const lots = await db.execute({
5547
- sql: `SELECT id, remaining_mc FROM credit_lots WHERE user_id = ? AND remaining_mc > 0 AND (never_expires = 1 OR expires_at > datetime('now')) ORDER BY never_expires ASC, expires_at ASC, granted_at ASC, id ASC`,
5548
- args: [userId]
5687
+ const inserted = await db.execute({
5688
+ sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, expires_at)
5689
+ SELECT ?, legacy.balance_mc, legacy.balance_mc, 'migration', datetime('now', ?)
5690
+ FROM (
5691
+ SELECT COALESCE(SUM(amount_mc), 0) AS balance_mc
5692
+ FROM ledger
5693
+ WHERE user_id = ?
5694
+ ) AS legacy
5695
+ WHERE legacy.balance_mc > 0
5696
+ AND EXISTS (
5697
+ SELECT 1 FROM ledger
5698
+ WHERE user_id = ? AND created_at < ?
5699
+ )
5700
+ AND NOT EXISTS (
5701
+ SELECT 1 FROM credit_lots WHERE user_id = ?
5702
+ )`,
5703
+ args: [userId, CREDIT_LOT_TTL, userId, userId, CREDIT_LOTS_EPOCH, userId]
5549
5704
  });
5550
- let need = mc;
5551
- const updates = [];
5552
- for (const lot of lots.rows) {
5553
- if (need <= 0) break;
5554
- const take = Math.min(Number(lot.remaining_mc), need);
5555
- updates.push({ sql: "UPDATE credit_lots SET remaining_mc = remaining_mc - ? WHERE id = ?", args: [take, Number(lot.id)] });
5556
- need -= take;
5557
- }
5558
- if (updates.length) await db.batch(updates, "write");
5705
+ return inserted.rowsAffected;
5559
5706
  }
5560
5707
  async function expireOldLots() {
5561
5708
  const db = getDb();
5562
5709
  const lots = await db.execute({
5563
- 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`
5564
5716
  });
5565
5717
  if (lots.rows.length === 0) return { expired_lots: 0, expired_mc: 0 };
5566
- let totalMc = 0;
5567
- const users = /* @__PURE__ */ new Set();
5718
+ const claims = [];
5568
5719
  const stmts = [];
5569
5720
  for (const lot of lots.rows) {
5570
- const rem = Number(lot.remaining_mc);
5571
- const uid = Number(lot.user_id);
5572
- totalMc += rem;
5573
- users.add(uid);
5574
- stmts.push({ sql: "UPDATE credit_lots SET remaining_mc = 0 WHERE id = ?", args: [Number(lot.id)] });
5575
- stmts.push({ sql: `INSERT INTO ledger (user_id, amount_mc, operation, description) VALUES (?, ?, 'credit_expired', 'credits expired')`, args: [uid, -rem] });
5576
- }
5577
- 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)));
5578
5763
  for (const uid of users) await reconcileBalanceMc(uid);
5579
- 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
+ };
5580
5768
  }
5581
5769
  async function migrateExistingBalancesToLots() {
5582
5770
  const db = getDb();
5583
5771
  const res = await db.execute({
5584
- sql: `SELECT u.id AS user_id, COALESCE(SUM(l.amount_mc), 0) AS bal
5585
- FROM users u LEFT JOIN ledger l ON l.user_id = u.id
5586
- WHERE u.id NOT IN (SELECT DISTINCT user_id FROM credit_lots)
5587
- GROUP BY u.id HAVING bal > 0`
5772
+ sql: `SELECT DISTINCT u.id AS user_id
5773
+ FROM users u
5774
+ JOIN ledger l ON l.user_id = u.id
5775
+ WHERE l.created_at < ?
5776
+ AND NOT EXISTS (SELECT 1 FROM credit_lots WHERE user_id = u.id)`,
5777
+ args: [CREDIT_LOTS_EPOCH]
5588
5778
  });
5589
5779
  let migrated = 0;
5590
5780
  let totalMc = 0;
5591
5781
  for (const row of res.rows) {
5592
5782
  const uid = Number(row.user_id);
5593
- const bal = Number(row.bal);
5594
- await db.execute({
5595
- sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, expires_at) VALUES (?, ?, ?, 'migration', datetime('now', ?))`,
5596
- args: [uid, bal, bal, CREDIT_LOT_TTL]
5597
- });
5783
+ const inserted = await ensureMigrated(uid);
5784
+ if (inserted === 0) continue;
5785
+ const lot = await db.execute({
5786
+ sql: `SELECT amount_mc FROM credit_lots
5787
+ WHERE user_id = ? AND source = 'migration'
5788
+ ORDER BY id DESC LIMIT 1`,
5789
+ args: [uid]
5790
+ });
5791
+ const amountMc = Number(lot.rows[0]?.amount_mc ?? 0);
5598
5792
  await reconcileBalanceMc(uid);
5599
- migrated++;
5600
- totalMc += bal;
5793
+ migrated += 1;
5794
+ totalMc += amountMc;
5601
5795
  }
5602
5796
  return { migrated, total_mc: totalMc };
5603
5797
  }
@@ -5611,19 +5805,22 @@ async function creditMc(userId, mc, operation, description, stripePaymentIntent,
5611
5805
  }
5612
5806
  const expiry = neverExpires ? "9999-12-31 23:59:59" : null;
5613
5807
  if (stripePaymentIntent) {
5614
- const ledgerInsert = await db.execute({
5808
+ const stmts2 = [{
5615
5809
  sql: "INSERT OR IGNORE INTO ledger (user_id, amount_mc, operation, description, stripe_pi) VALUES (?, ?, ?, ?, ?)",
5616
5810
  args: [userId, mc, operation, description ?? null, stripePaymentIntent]
5617
- });
5618
- if (ledgerInsert.rowsAffected === 0) {
5619
- const res3 = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
5620
- return Number(res3.rows[0]?.balance_mc ?? 0);
5621
- }
5622
- const stmts2 = [];
5811
+ }];
5623
5812
  if (mc > 0) {
5624
- stmts2.push({ sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, stripe_pi, expires_at, never_expires) VALUES (?, ?, ?, ?, ?, COALESCE(?, datetime('now', ?)), ?)`, args: [userId, mc, mc, operation, stripePaymentIntent, expiry, CREDIT_LOT_TTL, neverExpires ? 1 : 0] });
5813
+ stmts2.push({
5814
+ sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, stripe_pi, expires_at, never_expires)
5815
+ SELECT ?, ?, ?, ?, ?, COALESCE(?, datetime('now', ?)), ?
5816
+ WHERE changes() = 1`,
5817
+ args: [userId, mc, mc, operation, stripePaymentIntent, expiry, CREDIT_LOT_TTL, neverExpires ? 1 : 0]
5818
+ });
5625
5819
  }
5626
- stmts2.push({ sql: "UPDATE users SET balance_mc = balance_mc + ? WHERE id = ?", args: [mc, userId] });
5820
+ stmts2.push({
5821
+ sql: "UPDATE users SET balance_mc = balance_mc + ? WHERE id = ? AND changes() = 1",
5822
+ args: [mc, userId]
5823
+ });
5627
5824
  await db.batch(stmts2, "write");
5628
5825
  const res2 = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
5629
5826
  return Number(res2.rows[0]?.balance_mc ?? 0);
@@ -5731,23 +5928,14 @@ async function claimMonthlyFreeRefresh(userId, month) {
5731
5928
  return res.rowsAffected > 0;
5732
5929
  }
5733
5930
  async function debitMc(userId, mc, operation, description) {
5734
- const db = getDb();
5735
- await ensureMigrated(userId);
5736
- const upd = await db.execute({
5737
- sql: "UPDATE users SET balance_mc = balance_mc - ? WHERE id = ? AND balance_mc >= ?",
5738
- args: [mc, userId, mc]
5739
- });
5740
- if (upd.rowsAffected === 0) {
5741
- const res2 = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
5742
- return { ok: false, balance_mc: Number(res2.rows[0]?.balance_mc ?? 0) };
5743
- }
5744
- await drainLots(userId, mc);
5745
- await db.execute({
5746
- sql: "INSERT INTO ledger (user_id, amount_mc, operation, description) VALUES (?, ?, ?, ?)",
5747
- args: [userId, -mc, operation, description ?? null]
5748
- });
5749
- const res = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
5750
- return { ok: true, balance_mc: Number(res.rows[0]?.balance_mc ?? 0) };
5931
+ const debit = await debitMcIdempotent(
5932
+ userId,
5933
+ mc,
5934
+ operation,
5935
+ description ?? "",
5936
+ `debit:${(0, import_node_crypto.randomUUID)()}`
5937
+ );
5938
+ return { ok: debit.ok, balance_mc: debit.balance_mc };
5751
5939
  }
5752
5940
  async function debitMcIdempotent(userId, mc, operation, description, idempotencyKey2) {
5753
5941
  if (!Number.isSafeInteger(mc) || mc <= 0) throw new Error("idempotent debit amount must be a positive integer");
@@ -6166,29 +6354,46 @@ async function ledgerExistsForStripePI(stripePI) {
6166
6354
  }
6167
6355
  async function reconcileBalanceMc(userId, knownBalanceMc) {
6168
6356
  const db = getDb();
6169
- const res = await db.execute({
6170
- sql: `SELECT
6171
- COUNT(*) AS lot_count,
6172
- 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
6173
- FROM credit_lots WHERE user_id = ?`,
6174
- args: [userId]
6175
- });
6176
- let balanceMc = Number(res.rows[0]?.balance_mc ?? 0);
6177
- if (Number(res.rows[0]?.lot_count ?? 0) === 0) {
6178
- await ensureMigrated(Number(userId));
6179
- const migrated = await db.execute({
6180
- 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`,
6181
6371
  args: [userId]
6182
6372
  });
6183
- balanceMc = Number(migrated.rows[0]?.balance_mc ?? 0);
6184
- }
6185
- if (knownBalanceMc !== balanceMc) {
6186
- await db.execute({
6187
- sql: "UPDATE users SET balance_mc = ? WHERE id = ? AND balance_mc != ?",
6188
- 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]
6189
6389
  });
6390
+ if (updated.rowsAffected === 1) return projectedBalanceMc;
6190
6391
  }
6191
- 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);
6192
6397
  }
6193
6398
  async function listInboxMessages(userId) {
6194
6399
  const res = await getDb().execute({
@@ -6251,18 +6456,20 @@ async function listActiveUserIds() {
6251
6456
  const res = await getDb().execute("SELECT id FROM users WHERE active = 1");
6252
6457
  return res.rows.map((r) => Number(r.id));
6253
6458
  }
6254
- 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;
6255
6460
  var init_db = __esm({
6256
6461
  "src/api/db.ts"() {
6257
6462
  "use strict";
6258
6463
  import_http = require("@libsql/client/http");
6259
6464
  import_node_crypto = require("crypto");
6260
6465
  import_zod = require("zod");
6466
+ init_credit_lot_integrity();
6467
+ init_credit_lot_integrity();
6261
6468
  DB_URL = process.env.TURSO_DATABASE_URL ?? "file:./paa-api.db";
6262
6469
  DB_TOKEN = process.env.TURSO_AUTH_TOKEN;
6263
6470
  _db = null;
6264
6471
  _rateLimitSchemaReady = false;
6265
- CORE_SCHEMA_VERSION = "2026-07-28.1";
6472
+ CORE_SCHEMA_VERSION = "2026-07-28.3";
6266
6473
  CORE_SCHEMA_TABLES = [
6267
6474
  "admin_credit_adjustment_events",
6268
6475
  "admin_credit_adjustments",
@@ -6274,7 +6481,9 @@ var init_db = __esm({
6274
6481
  "browser_profile_labels",
6275
6482
  "connected_account_billing",
6276
6483
  "concurrency_locks",
6484
+ "credit_lot_expirations",
6277
6485
  "credit_lots",
6486
+ "credit_lot_repairs",
6278
6487
  "free_credit_refreshes",
6279
6488
  "harvest_attempts",
6280
6489
  "inbox_messages",
@@ -6311,6 +6520,7 @@ var init_db = __esm({
6311
6520
  "concurrency_locks_operation",
6312
6521
  "concurrency_locks_user_status",
6313
6522
  "credit_lots_user_active",
6523
+ "credit_lot_repairs_user_applied",
6314
6524
  "harvest_attempts_job_id",
6315
6525
  "harvest_attempts_user_created_at",
6316
6526
  "inbox_messages_user_sent_at",
@@ -6340,6 +6550,12 @@ var init_db = __esm({
6340
6550
  "workflow_schedules_user_status",
6341
6551
  "workflow_webhook_deliveries_run"
6342
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
+ ];
6343
6559
  CORE_SCHEMA_COLUMNS = {
6344
6560
  credit_lots: ["never_expires"],
6345
6561
  harvest_attempts: ["debug_json"],
@@ -6357,6 +6573,7 @@ var init_db = __esm({
6357
6573
  "next_settlement_at",
6358
6574
  "settlement_error"
6359
6575
  ],
6576
+ stripe_events: ["attempt_count", "claim_token", "last_error", "status", "updated_at"],
6360
6577
  users: [
6361
6578
  "balance_mc",
6362
6579
  "concurrency_stripe_sub_id",
@@ -6448,7 +6665,7 @@ var init_cost_rates = __esm({
6448
6665
  "use strict";
6449
6666
  KERNEL_HEADLESS_USD_PER_SEC = 166667e-10;
6450
6667
  KERNEL_HEADFUL_USD_PER_SEC = 1333336e-10;
6451
- 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"]);
6452
6669
  FAL_WIZPER_USD_PER_COMPUTE_SEC = 111e-5;
6453
6670
  FAL_WIZPER_WALLCLOCK_TO_COMPUTE_SEC_FACTOR = 4;
6454
6671
  DEEPINFRA_QWEN_USD_PER_1K_OUT_TOKENS = 4e-4;
@@ -8044,12 +8261,15 @@ async function spiderSite(opts) {
8044
8261
  const discovered = { url, status, linkedFrom: from, via: "fetch" };
8045
8262
  const children = [];
8046
8263
  if (html) {
8047
- for (const link of extractLinks(html, url)) {
8048
- if (baseDomain(link) !== startDomain) continue;
8049
- if (!visited.has(link)) {
8050
- visited.add(link);
8051
- 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
+ }
8052
8271
  }
8272
+ } catch {
8053
8273
  }
8054
8274
  }
8055
8275
  return { discovered, children };
@@ -10029,67 +10249,78 @@ async function extractSite(opts) {
10029
10249
  let truncated = false;
10030
10250
  let spider;
10031
10251
  let sitemapUrls = [];
10032
- if (rotating) {
10033
- const browserConcurrency = Math.max(1, Math.min(opts.parallelism ?? 1, 8));
10034
- const urlsPerBrowser = opts.rotateProxyEvery ?? 10;
10035
- const followLinks = !seeded;
10036
- const seen = /* @__PURE__ */ new Set();
10037
- const frontier = [];
10038
- const enqueue = (u) => {
10039
- const n = normalizeUrl(u, opts.startUrl);
10040
- if (n && !seen.has(n)) {
10041
- seen.add(n);
10042
- frontier.push(n);
10043
- }
10044
- };
10045
- if (seeded) {
10046
- for (const u of opts.seedUrls) enqueue(u);
10047
- } else {
10048
- enqueue(opts.startUrl);
10049
- sitemapUrls = await discoverSitemapUrls2(opts.startUrl, maxPages);
10050
- for (const u of sitemapUrls) enqueue(u);
10051
- }
10052
- const waveCap = Math.max(browserConcurrency * urlsPerBrowser, 30);
10053
- let done = 0;
10054
- while (frontier.length > 0 && pages.length < maxPages) {
10055
- const capacity = maxPages - pages.length;
10056
- const wave = frontier.splice(0, Math.min(capacity, waveCap));
10057
- const fetched = await crawlWithRotation(wave, {
10058
- apiKey: opts.kernelApiKey,
10059
- concurrency: browserConcurrency,
10060
- urlsPerBrowser,
10061
- onPage: (p) => {
10062
- done++;
10063
- 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);
10064
10265
  }
10065
- });
10066
- for (const r of fetched) {
10067
- 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", {
10068
- code: r.errorCode ?? "browser_empty_response",
10069
- message: r.error ?? "Browser fetch returned no usable response.",
10070
- 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
+ }
10071
10287
  });
10072
- pages.push(page);
10073
- browserRetries++;
10074
- if (followLinks && isPageExtractionSuccessful(page)) {
10075
- 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
+ }
10076
10299
  }
10077
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;
10078
10314
  }
10079
- truncated = frontier.length > 0;
10080
- spider = makeSeedSpider(opts.startUrl, [...seen]);
10081
- } else {
10082
- spider = seeded ? makeSeedSpider(opts.startUrl, opts.seedUrls) : await spiderSite({ startUrl: opts.startUrl, maxUrls: maxPages, concurrency: 12, kernelApiKey: opts.kernelApiKey });
10083
- sitemapUrls = spider.sitemapUrls;
10084
- const urlsToExtract = spider.urls.filter((u) => u.status === 200 || u.status === null).slice(0, maxPages).map((u) => u.url);
10085
- browserRetries = spider.browserRetries;
10086
- await runWithConcurrency(urlsToExtract, concurrency, async (url) => {
10087
- const data = await fetchAndParse(url, opts.kernelApiKey);
10088
- if (data.via === "browser") browserRetries++;
10089
- pages.push(data);
10090
- });
10091
- 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;
10092
10322
  }
10323
+ if (!spider) spider = makeSeedSpider(opts.startUrl, pages.map((p) => p.url));
10093
10324
  const sitemapSet = sitemapUrls.length > 0 ? new Set(sitemapUrls.map((u) => normalizeUrl(u, opts.startUrl) ?? u)) : null;
10094
10325
  for (const p of pages) p.inSitemap = sitemapSet ? sitemapSet.has(normalizeUrl(p.url, opts.startUrl) ?? p.url) : null;
10095
10326
  let branding = null;
@@ -10099,10 +10330,11 @@ async function extractSite(opts) {
10099
10330
  const seoAudit = opts.formats?.includes("issues") ? buildSeoAudit(pages, opts.startUrl) : null;
10100
10331
  let imageAudit = null;
10101
10332
  if (opts.formats?.includes("images")) {
10102
- imageAudit = await auditImages(pages, { concurrency: 12, timeoutMs: 12e3 });
10333
+ imageAudit = await auditImages(pages, { concurrency: 12, timeoutMs: 12e3 }).catch(() => null);
10103
10334
  }
10104
10335
  return {
10105
10336
  startUrl: opts.startUrl,
10337
+ stoppedEarly,
10106
10338
  pages,
10107
10339
  totalPages: pages.length,
10108
10340
  spider,
@@ -10906,11 +11138,14 @@ var init_rates = __esm({
10906
11138
  url_map: 500,
10907
11139
  yt_channel: 200,
10908
11140
  yt_transcription: 200,
10909
- fb_ad: 200,
11141
+ fb_ad: 700,
10910
11142
  maps_search: 500,
10911
11143
  maps_place: 6e3,
10912
11144
  maps_review: 100,
10913
- fb_search: 600,
11145
+ fb_search: 800,
11146
+ fb_search_result: 100,
11147
+ fb_page_intel: 700,
11148
+ fb_page_intel_ad: 50,
10914
11149
  fb_transcribe: 33300,
10915
11150
  google_ads_search: 600,
10916
11151
  google_ads_intel: 200,
@@ -11289,6 +11524,12 @@ var init_rates = __esm({
11289
11524
  FB_SEARCH: "fb_search",
11290
11525
  FB_TRANSCRIBE: "fb_transcribe",
11291
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",
11292
11533
  FB_TRANSCRIBE_REFUND: "fb_transcribe_refund",
11293
11534
  GOOGLE_ADS_SEARCH: "google_ads_search",
11294
11535
  GOOGLE_ADS_SEARCH_REFUND: "google_ads_search_refund",
@@ -16240,8 +16481,12 @@ async function formatExtractSite(raw, input, ctx) {
16240
16481
  schemaTypes: schemaTypesOf(p)
16241
16482
  })));
16242
16483
  const preview = pages.slice(0, BULK_PAGE_THRESHOLD).map(pageRow).join("\n");
16484
+ const stoppedEarly = d.stoppedEarly;
16243
16485
  const summaryHeader = [
16244
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
+ ` : "",
16245
16490
  durationLine,
16246
16491
  `
16247
16492
  ## Pages (first ${Math.min(BULK_PAGE_THRESHOLD, pages.length)} of ${pages.length})
@@ -18783,8 +19028,10 @@ var init_BrowserDriver = __esm({
18783
19028
  return this.kernelClient ? "Google returned a CAPTCHA on this session \u2014 retrying with a fresh session." : RECAPTCHA_INSTRUCTIONS;
18784
19029
  }
18785
19030
  async navigateTo(url) {
19031
+ const arrivalBase = process.env.ADS_ARRIVAL_REDIRECT_BASE?.trim();
19032
+ const navUrl = arrivalBase ? serpArrivalUrl(url, arrivalBase) : url;
18786
19033
  try {
18787
- await this.page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
19034
+ await this.page.goto(navUrl, { waitUntil: "domcontentloaded", timeout: 45e3 });
18788
19035
  if (/google\.[^/]+\/search\?/i.test(url) && await this.isSerpChallengeVisible()) {
18789
19036
  throw new CaptchaError(this.captchaMessage());
18790
19037
  }
@@ -19000,6 +19247,30 @@ function closeOpenBusinessPanelDialogInBrowser(expectedHeading) {
19000
19247
  close?.click();
19001
19248
  return Boolean(close);
19002
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
+ }
19003
19274
  async function clickPanelRowWithRetry(page, businessName, prefix) {
19004
19275
  const input = { businessName, prefix };
19005
19276
  if (await page.evaluate(clickBusinessPanelRowInBrowser, input)) return true;
@@ -19013,9 +19284,12 @@ async function extractExpandedList(page, businessName, rowPrefix, dialogHeading)
19013
19284
  extractOpenBusinessPanelDialogItemsInBrowser,
19014
19285
  dialogHeading
19015
19286
  );
19016
- await page.evaluate(closeOpenBusinessPanelDialogInBrowser, dialogHeading);
19017
- await page.waitForTimeout(250);
19018
- 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 });
19019
19293
  }
19020
19294
  async function extractExpandedGoogleBusinessPanelServices(page, businessName) {
19021
19295
  const services = await extractExpandedList(page, businessName, "Services:", "Services");
@@ -19092,7 +19366,7 @@ var init_MapsSearchExtractor = __esm({
19092
19366
  await page.waitForTimeout(LOCAL_RESULTS_WAIT_MS);
19093
19367
  if (await this.detectBlock(page)) throw new CaptchaError(RECAPTCHA_INSTRUCTIONS);
19094
19368
  const reachedFinder = await this.clickIntoLocalFinder(page);
19095
- 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');
19096
19370
  await page.waitForTimeout(LOCAL_RESULTS_WAIT_MS);
19097
19371
  if (await this.detectBlock(page)) throw new CaptchaError(RECAPTCHA_INSTRUCTIONS);
19098
19372
  const results = await this.collectResults(page, options);
@@ -20078,7 +20352,8 @@ function shouldRetryMapsSearchError(input) {
20078
20352
  if (input.attemptIndex >= input.maxAttempts - 1) return false;
20079
20353
  if (!retryableMapsSearchError(input.err, input.proxyMode)) return false;
20080
20354
  if (input.err instanceof MapsSearchIncompletePageError) {
20081
- 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;
20082
20357
  }
20083
20358
  return true;
20084
20359
  }
@@ -20259,7 +20534,7 @@ async function runMapsSearchWithRotation(options) {
20259
20534
  true
20260
20535
  );
20261
20536
  }
20262
- 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;
20263
20538
  var init_maps_search_rotation = __esm({
20264
20539
  "src/maps-search-rotation.ts"() {
20265
20540
  "use strict";
@@ -20269,6 +20544,7 @@ var init_maps_search_rotation = __esm({
20269
20544
  init_kernel_proxy_resolver();
20270
20545
  init_backup_proxy();
20271
20546
  init_cost_context();
20547
+ UNCONFIRMED_PAGE_MAX_RETRIES = 5;
20272
20548
  MAPS_SEARCH_MAX_ATTEMPTS = 3;
20273
20549
  MAPS_SEARCH_NONE_MAX_ATTEMPTS = 7;
20274
20550
  MAPS_SEARCH_LOCATION_PROXY_MAX_ATTEMPTS = 5;
@@ -25801,6 +26077,7 @@ async function resolveYouTubeAudio(videoId) {
25801
26077
  if (!base || !secret2) return null;
25802
26078
  const controller = new AbortController();
25803
26079
  const timer = setTimeout(() => controller.abort(), RESOLVE_TIMEOUT_MS);
26080
+ const startedMs = Date.now();
25804
26081
  try {
25805
26082
  const resp = await fetch(`${base.replace(/\/+$/, "")}/resolve-audio`, {
25806
26083
  method: "POST",
@@ -25818,6 +26095,12 @@ async function resolveYouTubeAudio(videoId) {
25818
26095
  console.warn(`[audio-resolver] ${videoId}: response missing mediaUrl`);
25819
26096
  return null;
25820
26097
  }
26098
+ void recordVendorUsage({
26099
+ vendor: "frames_audio_resolve",
26100
+ model: null,
26101
+ units: (Date.now() - startedMs) / 1e3,
26102
+ unitType: "compute_sec"
26103
+ });
25821
26104
  return {
25822
26105
  mediaUrl: data.mediaUrl,
25823
26106
  durationS: Number(data.durationS) || 0,
@@ -25836,6 +26119,7 @@ var RESOLVE_TIMEOUT_MS;
25836
26119
  var init_audio_resolver = __esm({
25837
26120
  "src/youtube/audio-resolver.ts"() {
25838
26121
  "use strict";
26122
+ init_cost_telemetry();
25839
26123
  RESOLVE_TIMEOUT_MS = 24e4;
25840
26124
  }
25841
26125
  });
@@ -27408,10 +27692,20 @@ function isAllowedFacebookOrganicUrl(url) {
27408
27692
  async function transcribeFacebookVideoUrl(videoUrl, markdownTitle = "# Facebook Video Transcript") {
27409
27693
  return transcribeMediaUrl(videoUrl, markdownTitle);
27410
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
+ }
27411
27705
  async function detectSoftBlock(driver) {
27412
27706
  const page = driver.getPage();
27413
27707
  const bodyText = await page.evaluate(() => document.body?.innerText ?? "").catch(() => "");
27414
- return bodyText.length < 200 || /Log in|log in|Create new account|You must log in/.test(bodyText);
27708
+ return classifyAdLibraryPage(bodyText, page.url()) === "blocked";
27415
27709
  }
27416
27710
  function buildPageIntelUrl(body, country) {
27417
27711
  if (body.libraryId?.trim()) return `https://www.facebook.com/ads/library/?id=${FacebookAdExtractor.resolveLibraryId(body.libraryId.trim()) ?? body.libraryId.trim()}`;
@@ -27487,7 +27781,7 @@ async function kernelLaunchOptsResidential() {
27487
27781
  }
27488
27782
  return { headless: true, kernelApiKey: browserServiceApiKey(), kernelProxyId: proxyId, viewport: { width: 1280, height: 900 }, locale: "en-US" };
27489
27783
  }
27490
- 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;
27491
27785
  var init_facebook_ad_routes = __esm({
27492
27786
  "src/api/facebook-ad-routes.ts"() {
27493
27787
  "use strict";
@@ -27537,6 +27831,9 @@ var init_facebook_ad_routes = __esm({
27537
27831
  url: import_zod21.z.string().trim().min(1, "url is required"),
27538
27832
  filename: import_zod21.z.string().trim().optional()
27539
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;
27540
27837
  facebookAdApp = new import_hono7.Hono();
27541
27838
  facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
27542
27839
  const raw = await c.req.json().catch(() => ({}));
@@ -27597,26 +27894,27 @@ var init_facebook_ad_routes = __esm({
27597
27894
  const driver = new BrowserDriver();
27598
27895
  let refunded = false, debited = false;
27599
27896
  try {
27600
- const { ok: fbOk, balance_mc: fbBal } = await debitMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD, subject);
27601
- 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);
27602
27899
  debited = true;
27603
27900
  await driver.launch(await kernelLaunchOptsResidential());
27604
27901
  await driver.navigateTo(listingUrl);
27605
27902
  const extractor = new FacebookAdExtractor(driver);
27606
27903
  const result = await extractor.extractPageIntel(listingUrl, maxAds);
27607
- if (result.ads.length === 0 && await detectSoftBlock(driver)) {
27608
- await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "soft-block empty result");
27904
+ if (result.ads.length === 0) {
27609
27905
  refunded = true;
27610
- await logRequestEvent({ userId: fbUser.id, source: "facebook_page_intel", status: "failed", query: subject, error: "soft-block: empty result refunded" });
27611
- return c.json({ error: "soft-block: no ads returned (refunded)" }, 503);
27906
+ return await refundEmptyPageIntel(c, fbUser.id, subject, await detectSoftBlock(driver));
27612
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);
27613
27911
  const match = assessPageIntelMatch(body, result);
27614
27912
  const output = { ...result, ...match };
27615
27913
  await logRequestEvent({ userId: fbUser.id, source: "facebook_page_intel", status: "done", query: subject, resultCount: result.ads.length, result: output });
27616
27914
  return c.json(output);
27617
27915
  } catch (err) {
27618
27916
  const msg = err instanceof Error ? err.message : String(err);
27619
- 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");
27620
27918
  await logRequestEvent({ userId: fbUser.id, source: "facebook_page_intel", status: "failed", query: subject, error: msg });
27621
27919
  if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
27622
27920
  return c.json({ error: msg }, 503);
@@ -27757,10 +28055,16 @@ var init_facebook_ad_routes = __esm({
27757
28055
  debited = true;
27758
28056
  await driver.launch(await kernelLaunchOptsResidential());
27759
28057
  const page = driver.getPage();
27760
- 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
+ }
27761
28062
  const gqlAdvertisers = advertisersFromResults(collated, maxResults);
27762
28063
  if (gqlAdvertisers.length > 0) {
27763
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);
27764
28068
  const searchResult2 = { query: body.query.trim(), searchUrl, results: results2, via: "graphql" };
27765
28069
  await logRequestEvent({ userId: fbUser.id, source: "facebook_search", status: "done", query: body.query.trim(), resultCount: results2.length, result: searchResult2 });
27766
28070
  return c.json(searchResult2);
@@ -27773,7 +28077,7 @@ var init_facebook_ad_routes = __esm({
27773
28077
  await page.waitForTimeout(1e3);
27774
28078
  }
27775
28079
  const rawBodyText = await page.evaluate(() => document.body?.innerText ?? "");
27776
- if (rawBodyText.length < 200 || /Log in|You must log in|Create new account/.test(rawBodyText)) {
28080
+ if (classifyAdLibraryPage(rawBodyText, page.url()) === "blocked") {
27777
28081
  await creditMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH_REFUND, "soft-block empty result");
27778
28082
  searchRefunded = true;
27779
28083
  await logRequestEvent({ userId: fbUser.id, source: "facebook_search", status: "failed", query: body.query.trim(), error: "soft-block: empty result refunded" });
@@ -27814,8 +28118,11 @@ var init_facebook_ad_routes = __esm({
27814
28118
  await creditMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH_REFUND, "empty result");
27815
28119
  searchRefunded = true;
27816
28120
  await logRequestEvent({ userId: fbUser.id, source: "facebook_search", status: "failed", query: body.query.trim(), error: "empty result refunded" });
27817
- 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);
27818
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);
27819
28126
  const searchResult = { query: body.query.trim(), searchUrl, results };
27820
28127
  await logRequestEvent({ userId: fbUser.id, source: "facebook_search", status: "done", query: body.query.trim(), resultCount: results.length, result: searchResult });
27821
28128
  return c.json(searchResult);
@@ -27881,9 +28188,6 @@ var init_facebook_ad_routes = __esm({
27881
28188
  });
27882
28189
 
27883
28190
  // src/extractor/GoogleAdsExtractor.ts
27884
- function looksLikeDomain(q) {
27885
- return /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(q.trim()) && !q.includes(" ");
27886
- }
27887
28191
  var ORIGIN, GoogleAdsExtractor;
27888
28192
  var init_GoogleAdsExtractor = __esm({
27889
28193
  "src/extractor/GoogleAdsExtractor.ts"() {
@@ -27899,50 +28203,108 @@ var init_GoogleAdsExtractor = __esm({
27899
28203
  }
27900
28204
  async search(query, region, maxResults) {
27901
28205
  const q = query.trim();
27902
- const url = looksLikeDomain(q) ? `${ORIGIN}/?region=${encodeURIComponent(region)}&domain=${encodeURIComponent(q)}` : `${ORIGIN}/?region=${encodeURIComponent(region)}&query=${encodeURIComponent(q)}`;
27903
- await this.driver.navigateTo(url);
28206
+ await this.driver.navigateTo(`${ORIGIN}/?region=${encodeURIComponent(region)}`);
27904
28207
  const page = this.page();
27905
- await page.waitForTimeout(3500);
27906
- await this.autoScroll(page, 6);
27907
- const advertisers = await page.evaluate((max) => {
27908
- const map = /* @__PURE__ */ new Map();
27909
- const links = Array.from(document.querySelectorAll('a[href*="/advertiser/"]'));
27910
- for (const a of links) {
27911
- const m = a.getAttribute("href")?.match(/\/advertiser\/(AR[0-9]+)/);
27912
- if (!m) continue;
27913
- const id = m[1];
27914
- const card = a.closest("creative-preview, advertiser-preview") || a.parentElement;
27915
- const nameEl = card?.querySelector('[class*="advertiser"], [class*="name"]');
27916
- const name = (nameEl?.innerText || "").trim() || null;
27917
- const cur = map.get(id);
27918
- if (cur) cur.count++;
27919
- else map.set(id, { advertiserId: id, name, count: 1 });
27920
- }
27921
- return Array.from(map.values()).slice(0, max).map((v) => ({
27922
- advertiserId: v.advertiserId,
27923
- name: v.name,
27924
- approxAdCount: v.count
27925
- }));
27926
- }, 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
+ }));
27927
28244
  return {
27928
28245
  query: q,
27929
28246
  region,
27930
28247
  advertiserCount: advertisers.length,
27931
- advertisers: advertisers.map((a) => ({
27932
- advertiserId: a.advertiserId,
27933
- name: a.name,
27934
- domain: looksLikeDomain(q) ? q : null,
27935
- approxAdCount: a.approxAdCount,
27936
- detailUrl: `${ORIGIN}/advertiser/${a.advertiserId}?region=${region}`
27937
- }))
28248
+ advertisers,
28249
+ websiteCount: websites.length,
28250
+ websites
27938
28251
  };
27939
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
+ }
27940
28300
  async pageIntel(opts) {
27941
28301
  const { region, maxAds } = opts;
27942
28302
  let advertiserId = opts.advertiserId?.trim() || null;
27943
- if (!advertiserId && opts.domain) {
27944
- const s = await this.search(opts.domain, region, 1);
27945
- 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);
27946
28308
  }
27947
28309
  if (!advertiserId) {
27948
28310
  return { advertiserId: null, advertiserName: null, domain: opts.domain ?? null, region, totalCreatives: 0, videoCount: 0, imageCount: 0, textCount: 0, ads: [] };
@@ -28103,10 +28465,11 @@ var init_google_ads_routes = __esm({
28103
28465
  });
28104
28466
  GoogleAdsPageIntelBodySchema = import_zod22.z.object({
28105
28467
  advertiserId: import_zod22.z.string().trim().optional(),
28468
+ advertiserName: import_zod22.z.string().trim().optional(),
28106
28469
  domain: import_zod22.z.string().trim().optional(),
28107
28470
  region: import_zod22.z.string().trim().toUpperCase().length(2).optional(),
28108
28471
  maxAds: import_zod22.z.number().int().min(1).max(200).optional()
28109
- }).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)" });
28110
28473
  GoogleAdsTranscribeBodySchema = import_zod22.z.object({
28111
28474
  videoUrl: import_zod22.z.string().trim().min(1, "videoUrl is required")
28112
28475
  });
@@ -28132,13 +28495,13 @@ var init_google_ads_routes = __esm({
28132
28495
  const extractor = new GoogleAdsExtractor(driver);
28133
28496
  return extractor.search(body.query.trim(), region, maxResults);
28134
28497
  });
28135
- if (result.advertisers.length === 0) {
28498
+ if (result.advertisers.length === 0 && result.websites.length === 0) {
28136
28499
  await creditMc(user.id, MC_COSTS.google_ads_search, LedgerOperation.GOOGLE_ADS_SEARCH_REFUND, "empty result");
28137
28500
  refunded = true;
28138
28501
  await logRequestEvent({ userId: user.id, source: "google_ads_search", status: "failed", query: body.query.trim(), error: "empty result refunded" });
28139
28502
  return c.json({ error: "no advertisers found (refunded)" }, 503);
28140
28503
  }
28141
- 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 });
28142
28505
  return c.json(result);
28143
28506
  } catch (err) {
28144
28507
  const msg = err instanceof Error ? err.message : String(err);
@@ -28157,7 +28520,7 @@ var init_google_ads_routes = __esm({
28157
28520
  const body = parsed.data;
28158
28521
  const region = body.region ?? "US";
28159
28522
  const maxAds = Math.min(200, Math.max(1, body.maxAds ?? 50));
28160
- const queryLabel = body.advertiserId ?? body.domain ?? "";
28523
+ const queryLabel = body.advertiserId ?? body.domain ?? body.advertiserName ?? "";
28161
28524
  const user = c.get("user");
28162
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 } });
28163
28526
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
@@ -28170,7 +28533,7 @@ var init_google_ads_routes = __esm({
28170
28533
  const result = await runWithCostContext({ ...currentCostContext(), op: "google_ads_intel", userId: user.id }, async () => {
28171
28534
  await driver.launch(await kernelLaunchOptsResidential2());
28172
28535
  const extractor = new GoogleAdsExtractor(driver);
28173
- 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 });
28174
28537
  });
28175
28538
  if (result.ads.length === 0) {
28176
28539
  await creditMc(user.id, MC_COSTS.google_ads_intel, LedgerOperation.GOOGLE_ADS_INTEL_REFUND, "empty result");
@@ -29118,6 +29481,7 @@ var init_instagram_routes = __esm({
29118
29481
  init_db();
29119
29482
  init_rates();
29120
29483
  init_concurrency_gates();
29484
+ init_cost_context();
29121
29485
  init_blob_store();
29122
29486
  init_InstagramContentExtractor();
29123
29487
  init_media_transcription();
@@ -29181,6 +29545,12 @@ var init_instagram_routes = __esm({
29181
29545
  scrollDelayMs: body.scrollDelayMs,
29182
29546
  stableScrollLimit: body.stableScrollLimit
29183
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
+ }
29184
29554
  const response = { ...result, browser: launch.browser };
29185
29555
  await logRequestEvent({ userId: user.id, source: "instagram_profile_content", status: "done", query: target.profileUrl, resultCount: result.items.length, result: response });
29186
29556
  return c.json(response);
@@ -29304,7 +29674,10 @@ var init_instagram_routes = __esm({
29304
29674
  const tx = await debitMc(user.id, holdMc, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
29305
29675
  if (!tx.ok) return c.json(insufficientBalanceResponse(tx.balance_mc, holdMc), 402);
29306
29676
  transcriptDebited = true;
29307
- 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
+ );
29308
29681
  const settleMc = mediaTranscribeChargeMc(transcript.durationMs / 1e3);
29309
29682
  const diff = holdMc - settleMc;
29310
29683
  if (diff > 0) await creditMc(user.id, diff, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "real-cost reconcile");
@@ -30006,6 +30379,8 @@ var init_PAAExtractor = __esm({
30006
30379
  aioShareCaptured: null
30007
30380
  };
30008
30381
  aioShareUrlEarly = null;
30382
+ collectedRows = [];
30383
+ partialContext = null;
30009
30384
  normalizeQuestion(q) {
30010
30385
  return q.toLowerCase().replace(/[^\w\s]/g, "").replace(/\s+/g, " ").trim();
30011
30386
  }
@@ -30110,11 +30485,50 @@ var init_PAAExtractor = __esm({
30110
30485
  extracted_at: (/* @__PURE__ */ new Date()).toISOString()
30111
30486
  };
30112
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
+ }
30113
30526
  async runBFS(page, options, signal) {
30114
30527
  const seenKeys = /* @__PURE__ */ new Set();
30115
30528
  const seenQs = /* @__PURE__ */ new Set();
30116
30529
  const orderedQs = [];
30117
- const results = [];
30530
+ this.collectedRows = [];
30531
+ const results = this.collectedRows;
30118
30532
  const abandonedNeverReclick = /* @__PURE__ */ new Set();
30119
30533
  const clickedOnceEver = /* @__PURE__ */ new Set();
30120
30534
  const itemArgs = {
@@ -30726,6 +31140,8 @@ var init_PAAExtractor = __esm({
30726
31140
  const startMs = Date.now();
30727
31141
  this.completeness = { paaWithoutAnswer: 0, paaWithoutSource: 0, paaAnswersRecovered: 0, aioShareCaptured: null };
30728
31142
  this.aioShareUrlEarly = null;
31143
+ this.collectedRows = [];
31144
+ this.partialContext = { seed: options.query, location: options.location ?? null, startMs };
30729
31145
  const isMobile = options.device === "mobile";
30730
31146
  const config = {
30731
31147
  headless: options.headless,
@@ -31273,7 +31689,23 @@ async function extractOnce2(options, signal) {
31273
31689
  } : null);
31274
31690
  cleanup = await driver.close();
31275
31691
  }
31276
- 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 };
31277
31709
  }
31278
31710
  async function harvest(rawOptions) {
31279
31711
  const raw = typeof rawOptions === "object" && rawOptions !== null ? rawOptions : {};
@@ -32891,6 +33323,7 @@ var init_trustpilot_routes = __esm({
32891
33323
  const driver = new BrowserDriver();
32892
33324
  const allReviews = [];
32893
33325
  let pagesFetched = 0;
33326
+ let stoppedEarly = null;
32894
33327
  try {
32895
33328
  await driver.launch({
32896
33329
  headless: false,
@@ -32908,6 +33341,12 @@ var init_trustpilot_routes = __esm({
32908
33341
  if (cards.length === 0) break;
32909
33342
  allReviews.push(...cards);
32910
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
+ };
32911
33350
  } finally {
32912
33351
  await driver.close();
32913
33352
  }
@@ -32933,6 +33372,7 @@ var init_trustpilot_routes = __esm({
32933
33372
  pagesFetched,
32934
33373
  reviewCount: allReviews.length,
32935
33374
  reviews: allReviews,
33375
+ stoppedEarly,
32936
33376
  durationMs: Date.now() - startMs
32937
33377
  };
32938
33378
  await logRequestEvent({ userId: user.id, source: "trustpilot_reviews", status: "done", query: domain, resultCount: allReviews.length, result });
@@ -37478,7 +37918,7 @@ var PACKAGE_VERSION;
37478
37918
  var init_version = __esm({
37479
37919
  "src/version.ts"() {
37480
37920
  "use strict";
37481
- PACKAGE_VERSION = "0.38.0";
37921
+ PACKAGE_VERSION = "0.38.2";
37482
37922
  }
37483
37923
  });
37484
37924
 
@@ -37975,9 +38415,9 @@ var init_mcp_tool_schemas = __esm({
37975
38415
  const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
37976
38416
  const looksLikeIpv4 = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname);
37977
38417
  const looksLikeIpv6 = hostname.includes(":");
37978
- const looksLikeDomain2 = hostname.includes(".");
38418
+ const looksLikeDomain = hostname.includes(".");
37979
38419
  const looksLikeLocalhost = hostname === "localhost";
37980
- if (!looksLikeIpv4 && !looksLikeIpv6 && !looksLikeDomain2 && !looksLikeLocalhost) {
38420
+ if (!looksLikeIpv4 && !looksLikeIpv6 && !looksLikeDomain && !looksLikeLocalhost) {
37981
38421
  ctx.addIssue({ code: import_zod38.z.ZodIssueCode.custom, message: WEBSITE_URL_OR_DOMAIN_ERROR });
37982
38422
  return import_zod38.z.NEVER;
37983
38423
  }
@@ -38113,8 +38553,9 @@ var init_mcp_tool_schemas = __esm({
38113
38553
  maxResults: import_zod38.z.number().int().min(1).max(20).default(10).describe("Maximum advertisers to return. Default 10, maximum 20.")
38114
38554
  };
38115
38555
  GoogleAdsPageIntelInputSchema = {
38116
- 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."),
38117
- 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."),
38118
38559
  region: import_zod38.z.string().length(2).default("US").describe("Two-letter region code for where the ads are shown. Default US."),
38119
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.")
38120
38561
  };
@@ -38569,6 +39010,10 @@ var init_mcp_tool_schemas = __esm({
38569
39010
  };
38570
39011
  ExtractSiteOutputSchema = {
38571
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."),
38572
39017
  pageCount: import_zod38.z.number().int().min(0).optional().describe("Absent when background is true \u2014 the crawl has not finished yet."),
38573
39018
  pages: import_zod38.z.array(import_zod38.z.object({
38574
39019
  url: import_zod38.z.string(),
@@ -38948,9 +39393,15 @@ var init_mcp_tool_schemas = __esm({
38948
39393
  advertisers: import_zod38.z.array(import_zod38.z.object({
38949
39394
  advertiserId: NullableString,
38950
39395
  name: NullableString,
39396
+ basedIn: NullableString,
38951
39397
  domain: NullableString,
38952
39398
  approxAdCount: import_zod38.z.number().int().nullable(),
38953
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()
38954
39405
  }))
38955
39406
  };
38956
39407
  GoogleAdsPageIntelOutputSchema = {
@@ -40267,14 +40718,14 @@ function registerPaaExtractorMcpTools(server, executor, options = {}) {
40267
40718
  }, async (input) => formatFacebookAdTranscribe(await executor.facebookAdTranscribe(input), input));
40268
40719
  server.registerTool("google_ads_search", {
40269
40720
  title: "Google Ads Transparency Search",
40270
- 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.",
40271
40722
  inputSchema: GoogleAdsSearchInputSchema,
40272
40723
  outputSchema: recordOutputSchema("google_ads_search", GoogleAdsSearchOutputSchema),
40273
40724
  annotations: liveWebToolAnnotations("Google Ads Transparency Search")
40274
40725
  }, async (input) => formatGoogleAdsSearch(await executor.googleAdsSearch(input), input));
40275
40726
  server.registerTool("google_ads_page_intel", {
40276
40727
  title: "Google Ads Advertiser Intel",
40277
- 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.",
40278
40729
  inputSchema: GoogleAdsPageIntelInputSchema,
40279
40730
  outputSchema: recordOutputSchema("google_ads_page_intel", GoogleAdsPageIntelOutputSchema),
40280
40731
  annotations: liveWebToolAnnotations("Google Ads Advertiser Intel")
@@ -48348,6 +48799,16 @@ var init_browser_agent_console = __esm({
48348
48799
  }
48349
48800
  });
48350
48801
 
48802
+ // src/api/stripe-credit-policy.ts
48803
+ function shouldGrantScraperSubscriptionCredits(invoiceHasTierLine, invoiceTotal) {
48804
+ return invoiceHasTierLine && (invoiceTotal ?? 0) > 0;
48805
+ }
48806
+ var init_stripe_credit_policy = __esm({
48807
+ "src/api/stripe-credit-policy.ts"() {
48808
+ "use strict";
48809
+ }
48810
+ });
48811
+
48351
48812
  // src/api/stripe-routes.ts
48352
48813
  async function cancelStandaloneMemorySub(memSubId, baseSubId) {
48353
48814
  if (!memSubId || memSubId === baseSubId) return;
@@ -48362,6 +48823,106 @@ async function cancelStandaloneMemorySub(memSubId, baseSubId) {
48362
48823
  console.error("[stripe] could not wind down standalone memory subscription", memSubId, err instanceof Error ? err.message : err);
48363
48824
  }
48364
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
+ }
48365
48926
  function linePriceId(line) {
48366
48927
  const l = line;
48367
48928
  return l?.price?.id ?? l?.pricing?.price_details?.price ?? l?.plan?.id;
@@ -48384,6 +48945,7 @@ var init_stripe_routes = __esm({
48384
48945
  init_rates();
48385
48946
  init_memory();
48386
48947
  init_connected_account_billing();
48948
+ init_stripe_credit_policy();
48387
48949
  stripe = new import_stripe.default(process.env.STRIPE_SECRET_KEY, { apiVersion: "2026-02-25.clover" });
48388
48950
  stripeApp = new import_hono22.Hono();
48389
48951
  stripeApp.post("/webhooks", async (c) => {
@@ -48395,107 +48957,19 @@ var init_stripe_routes = __esm({
48395
48957
  } catch {
48396
48958
  return c.json({ error: "Invalid signature" }, 400);
48397
48959
  }
48398
- const isNew = await recordStripeEvent(event.id);
48399
- if (!isNew) return c.json({ received: true });
48400
- if (event.type === "invoice.paid" || event.type === "invoice.payment_succeeded") {
48401
- const invoice = event.data.object;
48402
- const memLineId = invoice.lines.data.map(linePriceId).find((id) => id && id in MEMORY_PLANS);
48403
- if (memLineId) return c.json({ received: true });
48404
- const invoiceHasTierLine = invoice.lines.data.some((l) => {
48405
- const id = linePriceId(l);
48406
- return id && id in SUBSCRIPTION_TIERS;
48407
- });
48408
- if (invoiceHasTierLine) {
48409
- const subId = invoice.subscription;
48410
- const liveBasePlanPriceId = subId ? findBasePlanItem(await stripe.subscriptions.retrieve(subId))?.price?.id : void 0;
48411
- const tierPriceId = liveBasePlanPriceId ?? invoice.lines.data.map(linePriceId).find((id) => id && id in SUBSCRIPTION_TIERS);
48412
- const tier = tierPriceId ? SUBSCRIPTION_TIERS[tierPriceId] : void 0;
48413
- if (tier) {
48414
- const user = await resolveUser3(invoice.customer, invoice.customer_email ?? void 0);
48415
- if (user && invoice.id && !await ledgerExistsForStripePI(invoice.id)) {
48416
- await creditMc(user.id, tier.credits_mc, LedgerOperation.SUBSCRIPTION, `${tier.label} subscription credits`, invoice.id, { neverExpires: tier.credits_never_expire });
48417
- await setSubscriptionTier(user.id, tier.tier, tier.concurrency, subId ?? user.subscription_id);
48418
- if (tier.includes_memory) {
48419
- await cancelStandaloneMemorySub(user.memory_subscription_id, subId ?? user.subscription_id);
48420
- await setMemoryPlan(user.id, "pro", null);
48421
- await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: "pro", subscription_tier: tier.tier }));
48422
- }
48423
- const credentials = await syncScheduledActionCredentials(user);
48424
- if (!credentials.ok) console.warn("[stripe] scheduled-action credential sync failed:", credentials.error ?? "unknown error");
48425
- }
48426
- }
48427
- }
48428
- }
48429
- if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
48430
- const sub = event.data.object;
48431
- const priceId = findBasePlanItem(sub)?.price?.id ?? sub.items.data[0]?.price?.id;
48432
- if (priceId && priceId in MEMORY_PLANS) {
48433
- const mem = MEMORY_PLANS[priceId];
48434
- const user2 = await resolveUser3(sub.customer, sub.customer_email);
48435
- if (user2) {
48436
- const live = sub.status === "active" || sub.status === "trialing";
48437
- const newMemoryPlan = live ? mem.plan : "free";
48438
- await setMemoryPlan(user2.id, newMemoryPlan, live ? sub.id : null);
48439
- await syncMemoryKeyPlan(user2, resolveEffectiveMemoryPlan({ ...user2, memory_plan: newMemoryPlan }));
48440
- }
48441
- return c.json({ received: true });
48442
- }
48443
- const user = await getUserByStripeCustomerId(sub.customer);
48444
- if (!user) return c.json({ received: true });
48445
- if (priceId && priceId in SUBSCRIPTION_TIERS) {
48446
- const tier = SUBSCRIPTION_TIERS[priceId];
48447
- const live = sub.status === "active" || sub.status === "trialing";
48448
- const newTier = live ? tier.tier : null;
48449
- await setSubscriptionTier(user.id, newTier, live ? tier.concurrency : 0, live ? sub.id : null);
48450
- if (tier.includes_memory) {
48451
- const newMemoryPlan = live ? "pro" : "free";
48452
- if (live) await cancelStandaloneMemorySub(user.memory_subscription_id, sub.id);
48453
- await setMemoryPlan(user.id, newMemoryPlan, live ? sub.id : null);
48454
- await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: newMemoryPlan }));
48455
- } else if (user.memory_subscription_id === sub.id) {
48456
- await setMemoryPlan(user.id, "free", null);
48457
- await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: "free" }));
48458
- }
48459
- if (live) {
48460
- const credentials = await syncScheduledActionCredentials(user);
48461
- if (!credentials.ok) console.warn("[stripe] scheduled-action credential sync failed:", credentials.error ?? "unknown error");
48462
- }
48463
- } else if (priceId === CONCURRENCY_PRICE_ID && event.type === "customer.subscription.created") {
48464
- await setExtraConcurrencySlots(user.id, user.extra_concurrency_slots + 1);
48465
- await setConcurrencySubId(user.id, sub.id);
48466
- }
48467
- }
48468
- if (event.type === "customer.subscription.deleted") {
48469
- const sub = event.data.object;
48470
- const priceId = findBasePlanItem(sub)?.price?.id ?? sub.items.data[0]?.price?.id;
48471
- if (priceId && priceId in SUBSCRIPTION_TIERS) {
48472
- const tier = SUBSCRIPTION_TIERS[priceId];
48473
- const user2 = await getUserByStripeCustomerId(sub.customer);
48474
- if (user2) {
48475
- await setSubscriptionTier(user2.id, null, 0, null);
48476
- if (tier.includes_memory || user2.memory_subscription_id === sub.id) {
48477
- await setMemoryPlan(user2.id, "free", null);
48478
- await syncMemoryKeyPlan(user2, resolveEffectiveMemoryPlan({ ...user2, memory_plan: "free" }));
48479
- }
48480
- }
48481
- return c.json({ received: true });
48482
- }
48483
- if (priceId && priceId in MEMORY_PLANS || await resolveUserByMemorySubscription(sub.id)) {
48484
- const user2 = await resolveUserByMemorySubscription(sub.id) ?? await getUserByStripeCustomerId(sub.customer);
48485
- if (user2) {
48486
- await setMemoryPlan(user2.id, "free", null);
48487
- await syncMemoryKeyPlan(user2, resolveEffectiveMemoryPlan({ ...user2, memory_plan: "free" }));
48488
- }
48489
- return c.json({ received: true });
48490
- }
48491
- const user = await getUserByStripeCustomerId(sub.customer);
48492
- if (!user) return c.json({ received: true });
48493
- if (priceId === CONCURRENCY_PRICE_ID) {
48494
- await setExtraConcurrencySlots(user.id, Math.max(0, user.extra_concurrency_slots - 1));
48495
- await setConcurrencySubId(user.id, null);
48496
- }
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);
48497
48972
  }
48498
- return c.json({ received: true });
48499
48973
  });
48500
48974
  }
48501
48975
  });
@@ -55168,6 +55642,7 @@ function opForCostPath(p) {
55168
55642
  if (p.startsWith("/facebook/")) {
55169
55643
  if (p.endsWith("transcribe")) return "fb_transcribe";
55170
55644
  if (p.endsWith("/search")) return "fb_search";
55645
+ if (p.endsWith("/page-intel")) return "fb_page_intel";
55171
55646
  return "fb_ad";
55172
55647
  }
55173
55648
  if (p.startsWith("/instagram/")) return "instagram";