mcp-scraper 0.33.7 → 0.34.1

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 (57) hide show
  1. package/README.md +4 -4
  2. package/dist/bin/api-server.cjs +7100 -7023
  3. package/dist/bin/api-server.cjs.map +1 -1
  4. package/dist/bin/api-server.js +2 -2
  5. package/dist/bin/mcp-scraper-cli.cjs +2 -1
  6. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  7. package/dist/bin/mcp-scraper-cli.js +3 -3
  8. package/dist/bin/mcp-scraper-install.cjs +2 -2
  9. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  10. package/dist/bin/mcp-scraper-install.js +2 -2
  11. package/dist/bin/mcp-stdio-server.cjs +43 -37
  12. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  13. package/dist/bin/mcp-stdio-server.js +6 -6
  14. package/dist/bin/paa-harvest.cjs +22 -0
  15. package/dist/bin/paa-harvest.cjs.map +1 -1
  16. package/dist/bin/paa-harvest.js +3 -3
  17. package/dist/{chunk-V36LS5YV.js → chunk-4ZB3X6BQ.js} +4 -3
  18. package/dist/chunk-4ZB3X6BQ.js.map +1 -0
  19. package/dist/{chunk-SUOHUXQS.js → chunk-5PZ6N2QM.js} +2 -2
  20. package/dist/{chunk-H6QECE2U.js → chunk-6OPHG76G.js} +30 -27
  21. package/dist/chunk-6OPHG76G.js.map +1 -0
  22. package/dist/{chunk-2EDFOQD7.js → chunk-7AYRWAEK.js} +2 -2
  23. package/dist/{chunk-2EDFOQD7.js.map → chunk-7AYRWAEK.js.map} +1 -1
  24. package/dist/chunk-7N2KYL4U.js +7 -0
  25. package/dist/chunk-7N2KYL4U.js.map +1 -0
  26. package/dist/{chunk-JBSGBSGT.js → chunk-BWXLTWF7.js} +21 -16
  27. package/dist/chunk-BWXLTWF7.js.map +1 -0
  28. package/dist/{chunk-V73MPRU6.js → chunk-CB5C3BPB.js} +17 -1
  29. package/dist/chunk-CB5C3BPB.js.map +1 -0
  30. package/dist/{chunk-XVCETVYY.js → chunk-XVVNKASZ.js} +12 -4
  31. package/dist/chunk-XVVNKASZ.js.map +1 -0
  32. package/dist/{chunk-AT2PFWHW.js → chunk-ZID3WQID.js} +2 -2
  33. package/dist/index.cjs +22 -0
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +3 -0
  36. package/dist/index.d.ts +3 -0
  37. package/dist/index.js +3 -3
  38. package/dist/{server-B35CQJB3.js → server-IWDHTES2.js} +185 -139
  39. package/dist/server-IWDHTES2.js.map +1 -0
  40. package/dist/{site-extract-repository-JHMVHENZ.js → site-extract-repository-OLVWMOU2.js} +3 -3
  41. package/dist/{worker-3LYNVGLF.js → worker-645BZPEK.js} +5 -5
  42. package/docs/mcp-tool-craft-lint.generated.md +171 -49
  43. package/docs/mcp-tool-manifest.generated.json +46 -11
  44. package/docs/specs/query-fanout-transport-contract-fix.md +37 -0
  45. package/package.json +1 -1
  46. package/dist/chunk-H6QECE2U.js.map +0 -1
  47. package/dist/chunk-HAMN42SP.js +0 -7
  48. package/dist/chunk-HAMN42SP.js.map +0 -1
  49. package/dist/chunk-JBSGBSGT.js.map +0 -1
  50. package/dist/chunk-V36LS5YV.js.map +0 -1
  51. package/dist/chunk-V73MPRU6.js.map +0 -1
  52. package/dist/chunk-XVCETVYY.js.map +0 -1
  53. package/dist/server-B35CQJB3.js.map +0 -1
  54. /package/dist/{chunk-SUOHUXQS.js.map → chunk-5PZ6N2QM.js.map} +0 -0
  55. /package/dist/{chunk-AT2PFWHW.js.map → chunk-ZID3WQID.js.map} +0 -0
  56. /package/dist/{site-extract-repository-JHMVHENZ.js.map → site-extract-repository-OLVWMOU2.js.map} +0 -0
  57. /package/dist/{worker-3LYNVGLF.js.map → worker-645BZPEK.js.map} +0 -0
@@ -20,7 +20,7 @@ import {
20
20
  resolveBackupKernelProxyId,
21
21
  resolveKernelProxyId,
22
22
  serpArrivalUrl
23
- } from "./chunk-XVCETVYY.js";
23
+ } from "./chunk-XVVNKASZ.js";
24
24
  import {
25
25
  HttpMcpToolExecutor,
26
26
  assessTranscriptSignal,
@@ -40,7 +40,7 @@ import {
40
40
  sanitizeAttempts,
41
41
  sanitizeHarvestResult,
42
42
  transcribeMediaUrl
43
- } from "./chunk-H6QECE2U.js";
43
+ } from "./chunk-6OPHG76G.js";
44
44
  import {
45
45
  auditImages,
46
46
  buildLinkReport,
@@ -53,11 +53,10 @@ import {
53
53
  browserServiceProfileSaveChanges,
54
54
  browserServiceProxyId,
55
55
  currentCostContext,
56
- kernelCostUsd,
57
56
  recordKernelSession,
58
57
  recordVendorUsage,
59
58
  runWithCostContext
60
- } from "./chunk-V36LS5YV.js";
59
+ } from "./chunk-4ZB3X6BQ.js";
61
60
  import {
62
61
  csvRecords,
63
62
  listWorkflowDefinitions,
@@ -69,7 +68,7 @@ import {
69
68
  workflowOutputBaseDir,
70
69
  workflowStepCount,
71
70
  workflowSupportsSteps
72
- } from "./chunk-AT2PFWHW.js";
71
+ } from "./chunk-ZID3WQID.js";
73
72
  import "./chunk-K443GQY5.js";
74
73
  import {
75
74
  DEFAULT_MAPS_PROXY_MODE,
@@ -80,8 +79,8 @@ import {
80
79
  RawMapsHoursRowSchema,
81
80
  RawMapsOverviewSchema,
82
81
  RawMapsReviewStatsSchema
83
- } from "./chunk-V73MPRU6.js";
84
- import "./chunk-HAMN42SP.js";
82
+ } from "./chunk-CB5C3BPB.js";
83
+ import "./chunk-7N2KYL4U.js";
85
84
  import {
86
85
  completeExtractJob,
87
86
  countSuccessfulPages,
@@ -92,7 +91,7 @@ import {
92
91
  saveExtractPages,
93
92
  setExtractJobTotal,
94
93
  settleExtractJob
95
- } from "./chunk-SUOHUXQS.js";
94
+ } from "./chunk-5PZ6N2QM.js";
96
95
  import {
97
96
  BROWSER_OPEN_MIN_BALANCE_MC,
98
97
  CONCURRENCY_PRICE_ID,
@@ -110,7 +109,6 @@ import {
110
109
  MC_PER_CREDIT,
111
110
  MC_PER_USD,
112
111
  MEDIA_TRANSCRIBE_HOLD_MC,
113
- MEDIA_TRANSCRIBE_MARGIN_MULTIPLE,
114
112
  MEMORY_AI_MARGIN_MULTIPLE,
115
113
  MEMORY_PLANS,
116
114
  MEMORY_PLAN_QUOTA,
@@ -131,13 +129,14 @@ import {
131
129
  getPublicRatesPayload,
132
130
  insufficientBalanceResponse,
133
131
  mcToCredits,
132
+ mediaTranscribeChargeMc,
134
133
  offerClaimPathForTier,
135
134
  serpActualCostMc,
136
135
  tierAmountChargedUsd,
137
136
  tierBillingInterval,
138
137
  tierCreditsPerInterval,
139
138
  vendorCostUsdToMc
140
- } from "./chunk-JBSGBSGT.js";
139
+ } from "./chunk-BWXLTWF7.js";
141
140
  import {
142
141
  CaptchaError,
143
142
  ExtractionError,
@@ -10367,7 +10366,8 @@ var HarvestBodySchema = z14.object({
10367
10366
  proxyZip: z14.string().regex(/^\d{5}$/).optional(),
10368
10367
  debug: z14.boolean().optional(),
10369
10368
  serpOnly: z14.boolean().optional(),
10370
- pages: z14.number().int().min(1).max(2).optional()
10369
+ pages: z14.number().int().min(1).max(2).optional(),
10370
+ recency: z14.enum(["day", "week", "month", "year"]).optional()
10371
10371
  });
10372
10372
  var ExtractUrlBodySchema = z14.object({
10373
10373
  url: z14.string().min(1, "url is required"),
@@ -10653,8 +10653,7 @@ youtubeApp.post("/transcribe", createApiKeyAuth(), async (c) => {
10653
10653
  const { videoId } = parsed.data;
10654
10654
  const id = videoId.trim();
10655
10655
  const user = c.get("user");
10656
- const holdMins = 5;
10657
- const holdMc = MC_COSTS.yt_transcription * holdMins;
10656
+ const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
10658
10657
  const gate = await acquireConcurrencyGate(user, "youtube_transcribe", {
10659
10658
  reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
10660
10659
  metadata: { videoId: id }
@@ -10670,8 +10669,7 @@ youtubeApp.post("/transcribe", createApiKeyAuth(), async (c) => {
10670
10669
  const chunkSecs = lastChunk && Number.isFinite(lastChunk.timestamp[1]) ? lastChunk.timestamp[1] : 0;
10671
10670
  const durationSecs = Number.isFinite(result.durationMs) ? result.durationMs / 1e3 : 0;
10672
10671
  const videoSecs = Math.max(chunkSecs, durationSecs) || 60;
10673
- const actualMins = Math.max(1, Math.ceil(videoSecs / 60));
10674
- const actualMc = MC_COSTS.yt_transcription * actualMins;
10672
+ const actualMc = mediaTranscribeChargeMc(videoSecs);
10675
10673
  const diff = holdMc - actualMc;
10676
10674
  if (diff > 0) await creditMc(user.id, diff, LedgerOperation.TRANSCRIPTION_REFUND, "overestimate refund");
10677
10675
  else if (diff < 0) await debitMc(user.id, -diff, LedgerOperation.TRANSCRIPTION, id);
@@ -11940,7 +11938,7 @@ facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
11940
11938
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
11941
11939
  debited = true;
11942
11940
  const transcript = await transcribeFacebookVideoUrl(videoUrl, "# Facebook Ad Transcript");
11943
- const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
11941
+ const settleMc = mediaTranscribeChargeMc(transcript.durationMs / 1e3);
11944
11942
  const diff = holdMc - settleMc;
11945
11943
  if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "real-cost reconcile");
11946
11944
  else if (diff < 0) await debitMc(fbUser.id, -diff, LedgerOperation.FB_TRANSCRIBE, videoUrl);
@@ -11991,7 +11989,7 @@ facebookAdApp.post("/video-transcribe", createApiKeyAuth(), async (c) => {
11991
11989
  const video = await extractFacebookOrganicVideoFromPage(page, sourceUrl.href, body.quality);
11992
11990
  const kernelMs = Date.now() - kernelStartMs;
11993
11991
  const transcript = await transcribeFacebookVideoUrl(video.videoUrl, "# Facebook Organic Video Transcript");
11994
- const settleMc = Math.round((transcript.costUsd + kernelCostUsd(kernelMs, true)) * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
11992
+ const settleMc = mediaTranscribeChargeMc(transcript.durationMs / 1e3);
11995
11993
  const diff = holdMc - settleMc;
11996
11994
  if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "real-cost reconcile");
11997
11995
  else if (diff < 0) await debitMc(fbUser.id, -diff, LedgerOperation.FB_TRANSCRIBE, sourceUrl.href);
@@ -12474,7 +12472,7 @@ googleAdsApp.post("/transcribe", createApiKeyAuth(), async (c) => {
12474
12472
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
12475
12473
  debited = true;
12476
12474
  const transcript = await runWithCostContext({ ...currentCostContext(), op: "google_ads_transcribe", userId: user.id }, () => transcribeMediaUrl(videoUrl, "# Google Ad Transcript"));
12477
- const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
12475
+ const settleMc = mediaTranscribeChargeMc(transcript.durationMs / 1e3);
12478
12476
  const diff = holdMc - settleMc;
12479
12477
  if (diff > 0) await creditMc(user.id, diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE_REFUND, "real-cost reconcile");
12480
12478
  else if (diff < 0) await debitMc(user.id, -diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE, videoUrl);
@@ -13129,7 +13127,7 @@ instagramApp.post("/media-download", createApiKeyAuth(), async (c) => {
13129
13127
  if (!tx.ok) return c.json(insufficientBalanceResponse(tx.balance_mc, holdMc), 402);
13130
13128
  transcriptDebited = true;
13131
13129
  transcript = await transcribeMediaUrl(audioUrl, "# Instagram Media Transcript");
13132
- const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
13130
+ const settleMc = mediaTranscribeChargeMc(transcript.durationMs / 1e3);
13133
13131
  const diff = holdMc - settleMc;
13134
13132
  if (diff > 0) await creditMc(user.id, diff, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "real-cost reconcile");
13135
13133
  else if (diff < 0) await debitMc(user.id, -diff, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
@@ -13163,33 +13161,44 @@ import { Hono as Hono10 } from "hono";
13163
13161
  import { z as z19 } from "zod";
13164
13162
 
13165
13163
  // src/api/reddit-trending.ts
13166
- var PARSE_REDDIT_SEARCH = `(() => {
13167
- const txt = el => ((el && el.innerText) || '').trim();
13168
- const bodyText = (document.body && document.body.innerText) || '';
13169
- const blocked = /whoa there|blocked by network|you've been blocked|network (policy|security)|log in to your reddit account/i.test(bodyText);
13170
- const listing = document.querySelector('.search-result-listing');
13171
- const rows = [];
13172
- document.querySelectorAll('.search-result.search-result-link').forEach(r => {
13173
- const titleEl = r.querySelector('header.search-result-header a.search-title');
13174
- rows.push({
13175
- title: txt(titleEl),
13176
- url: (titleEl && titleEl.getAttribute('href')) || '',
13177
- subreddit: txt(r.querySelector('a.search-subreddit-link')),
13178
- scoreText: txt(r.querySelector('.search-score')),
13179
- commentsText: txt(r.querySelector('a.search-comments')),
13180
- ageText: txt(r.querySelector('.search-time time')),
13164
+ function buildSerpQuery(topic, subreddit) {
13165
+ const scope = subreddit ? `site:reddit.com/r/${subreddit}` : "site:reddit.com";
13166
+ return `${scope} ${topic}`.trim();
13167
+ }
13168
+ function subredditFromUrl(url) {
13169
+ const m = url.match(/\/r\/([^/]+)\//i);
13170
+ return m ? m[1] : "";
13171
+ }
13172
+ function extractRedditThreadUrls(organicResults, limit) {
13173
+ if (!Array.isArray(organicResults)) return [];
13174
+ const seen = /* @__PURE__ */ new Set();
13175
+ const out = [];
13176
+ for (const raw of organicResults) {
13177
+ if (!raw || typeof raw !== "object") continue;
13178
+ const r = raw;
13179
+ const url = canonicalThreadUrl(r.url);
13180
+ if (!url || seen.has(url)) continue;
13181
+ seen.add(url);
13182
+ out.push({
13183
+ title: typeof r.title === "string" ? r.title.trim() : "",
13184
+ url,
13185
+ subreddit: subredditFromUrl(url)
13181
13186
  });
13187
+ if (out.length >= limit) break;
13188
+ }
13189
+ return out;
13190
+ }
13191
+ async function mapWithConcurrency(items, concurrency, fn) {
13192
+ const results = new Array(items.length);
13193
+ let next = 0;
13194
+ const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, async () => {
13195
+ while (next < items.length) {
13196
+ const i = next++;
13197
+ results[i] = await fn(items[i], i);
13198
+ }
13182
13199
  });
13183
- return { blocked: blocked, listingFound: Boolean(listing), rowCount: rows.length, rows: rows };
13184
- })()`;
13185
- function buildRedditSearchUrl(topic, subreddit) {
13186
- const base = subreddit ? `https://old.reddit.com/r/${encodeURIComponent(subreddit)}/search` : "https://old.reddit.com/search";
13187
- const params = new URLSearchParams();
13188
- params.set("q", topic);
13189
- params.set("sort", "top");
13190
- params.set("t", "month");
13191
- if (subreddit) params.set("restrict_sr", "on");
13192
- return `${base}?${params.toString()}`;
13200
+ await Promise.all(workers);
13201
+ return results;
13193
13202
  }
13194
13203
  function parseCountText(text2) {
13195
13204
  if (typeof text2 !== "string") return 0;
@@ -13214,35 +13223,6 @@ function canonicalThreadUrl(href) {
13214
13223
  function engagementScore(score, commentCount) {
13215
13224
  return score + 2 * commentCount;
13216
13225
  }
13217
- function normalizeSearchRows(rows) {
13218
- if (!Array.isArray(rows)) return [];
13219
- const seen = /* @__PURE__ */ new Set();
13220
- const out = [];
13221
- for (const raw of rows) {
13222
- if (!raw || typeof raw !== "object") continue;
13223
- const r = raw;
13224
- const url = canonicalThreadUrl(r.url);
13225
- const title = typeof r.title === "string" ? r.title.trim() : "";
13226
- if (!url || !title || seen.has(url)) continue;
13227
- seen.add(url);
13228
- const score = parseCountText(r.scoreText);
13229
- const commentCount = parseCountText(r.commentsText);
13230
- out.push({
13231
- title,
13232
- url,
13233
- subreddit: typeof r.subreddit === "string" ? r.subreddit.trim() : "",
13234
- score,
13235
- commentCount,
13236
- engagementScore: engagementScore(score, commentCount),
13237
- ageText: typeof r.ageText === "string" ? r.ageText.trim() : "",
13238
- topQuestions: []
13239
- });
13240
- }
13241
- return out;
13242
- }
13243
- function rankThreads(rows, maxThreads) {
13244
- return [...rows].sort((a, b) => b.engagementScore - a.engagementScore).slice(0, maxThreads);
13245
- }
13246
13226
  var INTERROGATIVE_STARTERS = /* @__PURE__ */ new Set([
13247
13227
  "what",
13248
13228
  "whats",
@@ -13335,7 +13315,8 @@ var RedditThreadBodySchema = z19.object({
13335
13315
  var RedditTrendingBodySchema = z19.object({
13336
13316
  topic: z19.string().trim().min(1, "topic is required"),
13337
13317
  subreddit: z19.string().trim().regex(/^(r\/)?[A-Za-z0-9_]{2,24}$/i, "subreddit must be a bare name like SEO").optional(),
13338
- maxThreads: z19.number().int().min(1).max(25).default(10),
13318
+ window: z19.enum(["week", "month"]).default("month"),
13319
+ maxThreads: z19.number().int().min(1).max(40).default(20),
13339
13320
  includeComments: z19.boolean().default(true),
13340
13321
  maxCommentsPerThread: z19.number().int().min(1).max(200).default(50)
13341
13322
  });
@@ -13486,50 +13467,76 @@ redditApp.post("/thread", createApiKeyAuth(), async (c) => {
13486
13467
  await releaseConcurrencyGate(gate.lockId);
13487
13468
  }
13488
13469
  });
13470
+ var TRENDING_SOFT_DEADLINE_MS = 24e4;
13471
+ var TRENDING_SCRAPE_CONCURRENCY = 4;
13489
13472
  redditApp.post("/trending", createApiKeyAuth(), async (c) => {
13490
13473
  const raw = await c.req.json().catch(() => ({}));
13491
13474
  const parsed = RedditTrendingBodySchema.safeParse(raw);
13492
13475
  if (!parsed.success) return c.json(invalidRequest4(parsed.error.issues[0]?.message ?? "Invalid request"), 400);
13493
13476
  const body = parsed.data;
13494
13477
  const subreddit = body.subreddit ? body.subreddit.replace(/^r\//i, "") : void 0;
13495
- const searchUrl = buildRedditSearchUrl(body.topic, subreddit);
13478
+ const serpQuery = buildSerpQuery(body.topic, subreddit);
13496
13479
  const user = c.get("user");
13497
13480
  const gate = await acquireConcurrencyGate(user, "reddit_trending", {
13498
13481
  reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
13499
13482
  metadata: { topic: body.topic }
13500
13483
  });
13501
13484
  if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
13485
+ const softDeadlineMs = Date.now() + TRENDING_SOFT_DEADLINE_MS;
13502
13486
  let discoveryDebited = false;
13503
13487
  let discoveryRefunded = false;
13504
- let threadDebitMcOutstanding = 0;
13505
- let commentDebitMcOutstanding = 0;
13488
+ const outstanding = { thread: 0, comment: 0 };
13506
13489
  try {
13507
- const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD, searchUrl);
13490
+ const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD, serpQuery);
13508
13491
  if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.reddit_thread), 402);
13509
13492
  discoveryDebited = true;
13510
- const discovery = await scrapeOldRedditWithRetries(
13511
- searchUrl,
13512
- PARSE_REDDIT_SEARCH,
13513
- (data) => !data.blocked && data.listingFound
13514
- );
13515
- if (!discovery) {
13516
- await creditMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD_REFUND, "reddit blocked/empty after retries");
13517
- discoveryRefunded = true;
13518
- await logRequestEvent({ userId: user.id, source: "reddit_trending", status: "failed", query: body.topic, error: "reddit blocked/empty after retries" });
13519
- return c.json({ error: "Reddit blocked the request after retries (refunded)" }, 503);
13493
+ let candidates = [];
13494
+ try {
13495
+ const serp = await harvest({
13496
+ query: serpQuery,
13497
+ serpOnly: true,
13498
+ proxyMode: "none",
13499
+ pages: 2,
13500
+ recency: body.window,
13501
+ kernelApiKey: browserServiceApiKey(),
13502
+ format: "json",
13503
+ outputDir: "/tmp/reddit-trending-serp",
13504
+ softDeadlineMs
13505
+ });
13506
+ candidates = extractRedditThreadUrls(serp.organicResults, body.maxThreads);
13507
+ } catch {
13508
+ candidates = [];
13520
13509
  }
13521
- const rows = normalizeSearchRows(discovery.data.rows);
13522
- const ranked = rankThreads(rows, body.maxThreads);
13523
- let threadsScraped = 0;
13524
- if (body.includeComments) {
13525
- let insufficient = false;
13526
- for (const thread of ranked) {
13527
- if (insufficient) break;
13528
- const oldUrl = toOldReddit(thread.url);
13529
- if (!oldUrl) continue;
13530
- const threadDebit = await debitMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD, thread.url);
13531
- if (!threadDebit.ok) break;
13532
- threadDebitMcOutstanding += MC_COSTS.reddit_thread;
13510
+ if (candidates.length === 0) {
13511
+ await creditMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD_REFUND, "no reddit threads discovered");
13512
+ discoveryRefunded = true;
13513
+ await logRequestEvent({ userId: user.id, source: "reddit_trending", status: "failed", query: body.topic, error: "no reddit threads discovered" });
13514
+ return c.json({ error: "No Reddit threads found for that topic and window (refunded)" }, 404);
13515
+ }
13516
+ let ranked;
13517
+ if (!body.includeComments) {
13518
+ ranked = candidates.map((cand) => ({
13519
+ title: cand.title,
13520
+ url: cand.url,
13521
+ subreddit: cand.subreddit,
13522
+ score: 0,
13523
+ commentCount: 0,
13524
+ engagementScore: 0,
13525
+ ageText: "",
13526
+ topQuestions: []
13527
+ }));
13528
+ } else {
13529
+ let balanceExhausted = false;
13530
+ const scrapedThreads = await mapWithConcurrency(candidates, TRENDING_SCRAPE_CONCURRENCY, async (cand) => {
13531
+ if (balanceExhausted || Date.now() >= softDeadlineMs) return null;
13532
+ const oldUrl = toOldReddit(cand.url);
13533
+ if (!oldUrl) return null;
13534
+ const threadDebit = await debitMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD, cand.url);
13535
+ if (!threadDebit.ok) {
13536
+ balanceExhausted = true;
13537
+ return null;
13538
+ }
13539
+ outstanding.thread += MC_COSTS.reddit_thread;
13533
13540
  const scraped = await scrapeOldRedditWithRetries(
13534
13541
  oldUrl,
13535
13542
  PARSE_REDDIT,
@@ -13537,46 +13544,57 @@ redditApp.post("/trending", createApiKeyAuth(), async (c) => {
13537
13544
  );
13538
13545
  if (!scraped) {
13539
13546
  await creditMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD_REFUND, "reddit blocked/empty after retries");
13540
- threadDebitMcOutstanding -= MC_COSTS.reddit_thread;
13541
- continue;
13547
+ outstanding.thread -= MC_COSTS.reddit_thread;
13548
+ return null;
13542
13549
  }
13550
+ const score = parseCountText(scraped.data.score);
13543
13551
  let comments = Array.isArray(scraped.data.comments) ? scraped.data.comments : [];
13552
+ const commentCount = comments.length;
13544
13553
  comments = comments.slice(0, body.maxCommentsPerThread);
13545
13554
  let commentMc = comments.length * MC_COSTS.reddit_comment;
13546
13555
  if (commentMc > 0) {
13547
- const commentDebit = await debitMc(user.id, commentMc, LedgerOperation.REDDIT_COMMENT, thread.url);
13556
+ const commentDebit = await debitMc(user.id, commentMc, LedgerOperation.REDDIT_COMMENT, cand.url);
13548
13557
  if (!commentDebit.ok) {
13558
+ balanceExhausted = true;
13549
13559
  const affordable = Math.floor((commentDebit.balance_mc ?? 0) / MC_COSTS.reddit_comment);
13550
13560
  comments = comments.slice(0, affordable);
13551
13561
  commentMc = affordable * MC_COSTS.reddit_comment;
13552
- if (commentMc > 0) await debitMc(user.id, commentMc, LedgerOperation.REDDIT_COMMENT, thread.url);
13553
- insufficient = true;
13562
+ if (commentMc > 0) await debitMc(user.id, commentMc, LedgerOperation.REDDIT_COMMENT, cand.url);
13554
13563
  }
13555
- commentDebitMcOutstanding += commentMc;
13564
+ outstanding.comment += commentMc;
13556
13565
  }
13557
13566
  const topLevelBodies = comments.filter((cm) => cm && cm.depth === 0).map((cm) => cm.body);
13558
- thread.topQuestions = extractQuestions([scraped.data.postBody, ...topLevelBodies]);
13559
- threadsScraped++;
13560
- }
13567
+ return {
13568
+ title: cand.title || scraped.data.title || "",
13569
+ url: cand.url,
13570
+ subreddit: cand.subreddit,
13571
+ score,
13572
+ commentCount,
13573
+ engagementScore: engagementScore(score, commentCount),
13574
+ ageText: "",
13575
+ topQuestions: extractQuestions([scraped.data.postBody, ...topLevelBodies])
13576
+ };
13577
+ });
13578
+ ranked = scrapedThreads.filter((t) => t !== null).sort((a, b) => b.engagementScore - a.engagementScore);
13561
13579
  }
13562
13580
  const result = {
13563
13581
  topic: body.topic,
13564
13582
  subreddit: subreddit ?? null,
13565
- window: "30d",
13583
+ window: body.window === "week" ? "7d" : "30d",
13566
13584
  totals: trendingTotals(ranked),
13567
13585
  rankedThreads: ranked,
13568
13586
  questions: flattenQuestions(ranked),
13569
- threadsScraped,
13570
- searchUrl,
13571
- attempts: discovery.attempts,
13572
- proxied: discovery.proxied
13587
+ threadsScraped: body.includeComments ? ranked.length : 0,
13588
+ candidatesFound: candidates.length,
13589
+ partial: body.includeComments && ranked.length < candidates.length,
13590
+ searchQuery: serpQuery
13573
13591
  };
13574
13592
  await logRequestEvent({ userId: user.id, source: "reddit_trending", status: "done", query: body.topic, resultCount: ranked.length, result });
13575
13593
  return c.json(result);
13576
13594
  } catch (err) {
13577
13595
  if (discoveryDebited && !discoveryRefunded) await creditMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD_REFUND, "failed call");
13578
- if (threadDebitMcOutstanding > 0) await creditMc(user.id, threadDebitMcOutstanding, LedgerOperation.REDDIT_THREAD_REFUND, "failed call");
13579
- if (commentDebitMcOutstanding > 0) await creditMc(user.id, commentDebitMcOutstanding, LedgerOperation.REDDIT_COMMENT_REFUND, "failed call");
13596
+ if (outstanding.thread > 0) await creditMc(user.id, outstanding.thread, LedgerOperation.REDDIT_THREAD_REFUND, "failed call");
13597
+ if (outstanding.comment > 0) await creditMc(user.id, outstanding.comment, LedgerOperation.REDDIT_COMMENT_REFUND, "failed call");
13580
13598
  const msg = err instanceof Error ? err.message : String(err);
13581
13599
  await logRequestEvent({ userId: user.id, source: "reddit_trending", status: "failed", query: body.topic, error: msg });
13582
13600
  return c.json({ error: msg }, 500);
@@ -20523,6 +20541,19 @@ if (state.key) { refreshSessions(); if (state.current) selectSession(state.curre
20523
20541
  import Stripe from "stripe";
20524
20542
  import { Hono as Hono20 } from "hono";
20525
20543
  var stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: "2026-02-25.clover" });
20544
+ async function cancelStandaloneMemorySub(memSubId, baseSubId) {
20545
+ if (!memSubId || memSubId === baseSubId) return;
20546
+ try {
20547
+ const memSub = await stripe.subscriptions.retrieve(memSubId);
20548
+ const priceId = memSub.items.data[0]?.price?.id;
20549
+ const stillOpen = memSub.status !== "canceled" && !memSub.cancel_at_period_end;
20550
+ if (priceId && priceId in MEMORY_PLANS && stillOpen) {
20551
+ await stripe.subscriptions.update(memSubId, { cancel_at_period_end: true });
20552
+ }
20553
+ } catch (err) {
20554
+ console.error("[stripe] could not wind down standalone memory subscription", memSubId, err instanceof Error ? err.message : err);
20555
+ }
20556
+ }
20526
20557
  var stripeApp = new Hono20();
20527
20558
  stripeApp.post("/webhooks", async (c) => {
20528
20559
  const sig = c.req.header("stripe-signature");
@@ -20554,14 +20585,7 @@ stripeApp.post("/webhooks", async (c) => {
20554
20585
  await creditMc(user.id, tier.credits_mc, LedgerOperation.SUBSCRIPTION, `${tier.label} subscription credits`, invoice.id, { neverExpires: tier.credits_never_expire });
20555
20586
  await setSubscriptionTier(user.id, tier.tier, tier.concurrency, subId ?? user.subscription_id);
20556
20587
  if (tier.includes_memory) {
20557
- const standaloneMemorySub = user.memory_subscription_id;
20558
- if (standaloneMemorySub) {
20559
- try {
20560
- await stripe.subscriptions.update(standaloneMemorySub, { cancel_at_period_end: true });
20561
- } catch (err) {
20562
- console.error("[stripe] could not wind down standalone memory subscription", standaloneMemorySub, err instanceof Error ? err.message : err);
20563
- }
20564
- }
20588
+ await cancelStandaloneMemorySub(user.memory_subscription_id, subId ?? user.subscription_id);
20565
20589
  await setMemoryPlan(user.id, "pro", null);
20566
20590
  await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: "pro", subscription_tier: tier.tier }));
20567
20591
  }
@@ -20613,6 +20637,7 @@ stripeApp.post("/webhooks", async (c) => {
20613
20637
  await setSubscriptionTier(user.id, newTier, live ? tier.concurrency : 0, live ? sub.id : null);
20614
20638
  if (tier.includes_memory) {
20615
20639
  const newMemoryPlan = live ? "pro" : "free";
20640
+ if (live) await cancelStandaloneMemorySub(user.memory_subscription_id, sub.id);
20616
20641
  await setMemoryPlan(user.id, newMemoryPlan, live ? sub.id : null);
20617
20642
  await syncMemoryKeyPlan(user, resolveEffectiveMemoryPlan({ ...user, memory_plan: newMemoryPlan }));
20618
20643
  } else if (user.memory_subscription_id === sub.id) {
@@ -21923,7 +21948,7 @@ async function readMemory(tool, args, token) {
21923
21948
  }
21924
21949
  return result;
21925
21950
  }
21926
- async function mapWithConcurrency(items, limit, map) {
21951
+ async function mapWithConcurrency2(items, limit, map) {
21927
21952
  const values = new Array(items.length);
21928
21953
  let cursor = 0;
21929
21954
  const worker = async () => {
@@ -21940,7 +21965,7 @@ async function readVault(vault, token) {
21940
21965
  const list = await readMemory("listTool", { vault }, token);
21941
21966
  if (!list.ok) throw new Error(list.error ?? `unable to read ${vault}`);
21942
21967
  const noteRefs = (list.notes ?? []).slice(0, MAX_VAULT_RECORDS);
21943
- const notes = await mapWithConcurrency(
21968
+ const notes = await mapWithConcurrency2(
21944
21969
  noteRefs,
21945
21970
  MAX_MEMORY_READ_CONCURRENCY,
21946
21971
  ({ path: path5 }) => readMemory("getTool", { vault, path: path5 }, token)
@@ -28160,7 +28185,8 @@ app.post("/harvest/sync", auth2, async (c) => {
28160
28185
  proxyZip: body.proxyZip,
28161
28186
  debug: body.debug ?? false,
28162
28187
  serpOnly: body.serpOnly ?? false,
28163
- pages: Math.min(2, Math.max(1, body.pages ?? 1))
28188
+ pages: Math.min(2, Math.max(1, body.pages ?? 1)),
28189
+ recency: body.recency
28164
28190
  };
28165
28191
  const syncCost = options.serpOnly ? MC_COSTS.serp_headful : paaCostForQuestionCount(options.maxQuestions);
28166
28192
  const { ok: syncOk, balance_mc: syncBal } = await debitMc(user.id, syncCost, options.serpOnly ? LedgerOperation.SERP : LedgerOperation.PAA, options.query);
@@ -28667,15 +28693,25 @@ app.post("/billing/subscribe", requireAllowedOrigin, sessionAuth, async (c) => {
28667
28693
  customerId = customer.id;
28668
28694
  await setStripeCustomerId(user.id, customerId);
28669
28695
  }
28670
- if (user.subscription_id) {
28671
- const sub = await stripeClient.subscriptions.retrieve(user.subscription_id);
28696
+ let existingSubId = user.subscription_id;
28697
+ if (!existingSubId) {
28698
+ try {
28699
+ const active = await stripeClient.subscriptions.list({ customer: customerId, status: "active", limit: 20 });
28700
+ const baseSub = active.data.find((s) => findBasePlanItem(s));
28701
+ if (baseSub) existingSubId = baseSub.id;
28702
+ } catch (err) {
28703
+ console.warn("[billing/subscribe] could not check for existing subscription", err instanceof Error ? err.message : err);
28704
+ }
28705
+ }
28706
+ if (existingSubId) {
28707
+ const sub = await stripeClient.subscriptions.retrieve(existingSubId);
28672
28708
  const itemId = findBasePlanItem(sub)?.id;
28673
28709
  if (itemId) {
28674
- await stripeClient.subscriptions.update(user.subscription_id, {
28710
+ await stripeClient.subscriptions.update(existingSubId, {
28675
28711
  items: [{ id: itemId, price: tier.price_id }],
28676
28712
  proration_behavior: "create_prorations"
28677
28713
  });
28678
- const billed = await chargeTierChangeNow(stripeClient, user.subscription_id, customerId);
28714
+ const billed = await chargeTierChangeNow(stripeClient, existingSubId, customerId);
28679
28715
  if (!billed.ok) {
28680
28716
  return c.json({
28681
28717
  updated: true,
@@ -28793,15 +28829,25 @@ app.post("/billing/subscribe/terminal-checkout", auth2, async (c) => {
28793
28829
  customerId = customer.id;
28794
28830
  await setStripeCustomerId(user.id, customerId);
28795
28831
  }
28796
- if (user.subscription_id) {
28797
- const sub = await stripeClient.subscriptions.retrieve(user.subscription_id);
28832
+ let existingSubId = user.subscription_id;
28833
+ if (!existingSubId) {
28834
+ try {
28835
+ const active = await stripeClient.subscriptions.list({ customer: customerId, status: "active", limit: 20 });
28836
+ const baseSub = active.data.find((s) => findBasePlanItem(s));
28837
+ if (baseSub) existingSubId = baseSub.id;
28838
+ } catch (err) {
28839
+ console.warn("[billing/subscribe] could not check for existing subscription", err instanceof Error ? err.message : err);
28840
+ }
28841
+ }
28842
+ if (existingSubId) {
28843
+ const sub = await stripeClient.subscriptions.retrieve(existingSubId);
28798
28844
  const itemId = findBasePlanItem(sub)?.id;
28799
28845
  if (itemId) {
28800
- await stripeClient.subscriptions.update(user.subscription_id, {
28846
+ await stripeClient.subscriptions.update(existingSubId, {
28801
28847
  items: [{ id: itemId, price: tier.price_id }],
28802
28848
  proration_behavior: "create_prorations"
28803
28849
  });
28804
- const billed = await chargeTierChangeNow(stripeClient, user.subscription_id, customerId);
28850
+ const billed = await chargeTierChangeNow(stripeClient, existingSubId, customerId);
28805
28851
  if (!billed.ok) {
28806
28852
  return c.json({
28807
28853
  updated: true,
@@ -28915,7 +28961,7 @@ app.get("/cron/tick", async (c) => {
28915
28961
  if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
28916
28962
  return c.json({ error: "Unauthorized" }, 401);
28917
28963
  }
28918
- const { drainQueue } = await import("./worker-3LYNVGLF.js");
28964
+ const { drainQueue } = await import("./worker-645BZPEK.js");
28919
28965
  const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
28920
28966
  const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
28921
28967
  const [results, sweepResult, reapResult, expiredResult, blobCleanup, connectedDataArtifactCleanup] = await Promise.all([
@@ -28943,7 +28989,7 @@ app.post("/api/internal/extract-refinalize/:id", async (c) => {
28943
28989
  return c.json({ error: "Unauthorized" }, 401);
28944
28990
  }
28945
28991
  const jobId = c.req.param("id");
28946
- const { getExtractJob: getExtractJob2, completeExtractJob: completeExtractJob2 } = await import("./site-extract-repository-JHMVHENZ.js");
28992
+ const { getExtractJob: getExtractJob2, completeExtractJob: completeExtractJob2 } = await import("./site-extract-repository-OLVWMOU2.js");
28947
28993
  const { assembleExtractArtifacts } = await import("./extract-bundle-GPZRLBVY.js");
28948
28994
  const job = await getExtractJob2(jobId);
28949
28995
  if (!job) return c.json({ error: "job not found" }, 404);
@@ -28951,7 +28997,7 @@ app.post("/api/internal/extract-refinalize/:id", async (c) => {
28951
28997
  await completeExtractJob2(jobId, stored);
28952
28998
  let settlement = "already_settled_or_refunded";
28953
28999
  if (job.billedMc == null && job.userId != null) {
28954
- const { settleExtractJob: settleExtractJob2, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-JHMVHENZ.js");
29000
+ const { settleExtractJob: settleExtractJob2, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-OLVWMOU2.js");
28955
29001
  const heldMc = Number(job.options.heldMc ?? 0);
28956
29002
  const successful = await countSuccessfulPages2(jobId);
28957
29003
  const usedMc = Math.min(successful * MC_COSTS.page_scrape, heldMc);
@@ -29120,4 +29166,4 @@ app.get("/blog/:slug/", (c) => {
29120
29166
  export {
29121
29167
  app
29122
29168
  };
29123
- //# sourceMappingURL=server-B35CQJB3.js.map
29169
+ //# sourceMappingURL=server-IWDHTES2.js.map