mcp-scraper 0.2.13 → 0.2.14
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.
- package/README.md +9 -0
- package/dist/bin/api-server.cjs +664 -178
- package/dist/bin/api-server.cjs.map +1 -1
- package/dist/bin/api-server.js +3 -3
- package/dist/bin/browser-agent-stdio-server.cjs +1 -1
- package/dist/bin/browser-agent-stdio-server.cjs.map +1 -1
- package/dist/bin/browser-agent-stdio-server.js +2 -2
- package/dist/bin/mcp-scraper-cli.cjs +46 -3
- package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
- package/dist/bin/mcp-scraper-cli.js +42 -2
- package/dist/bin/mcp-scraper-cli.js.map +1 -1
- package/dist/bin/mcp-scraper-combined-stdio-server.cjs +47 -5
- package/dist/bin/mcp-scraper-combined-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-scraper-combined-stdio-server.js +3 -3
- package/dist/bin/mcp-scraper-install.cjs +2 -1
- package/dist/bin/mcp-scraper-install.cjs.map +1 -1
- package/dist/bin/mcp-scraper-install.js +2 -1
- package/dist/bin/mcp-scraper-install.js.map +1 -1
- package/dist/bin/mcp-stdio-server.cjs +47 -5
- package/dist/bin/mcp-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-stdio-server.js +2 -2
- package/dist/{chunk-ROS67BNV.js → chunk-BSYPATSM.js} +21 -2
- package/dist/chunk-BSYPATSM.js.map +1 -0
- package/dist/{chunk-AYTOCZBS.js → chunk-F44RBOJ5.js} +6 -3
- package/dist/chunk-F44RBOJ5.js.map +1 -0
- package/dist/{chunk-DKJ2XCY7.js → chunk-IWQTNY2E.js} +48 -6
- package/dist/chunk-IWQTNY2E.js.map +1 -0
- package/dist/chunk-ROCXJOVY.js +7 -0
- package/dist/chunk-ROCXJOVY.js.map +1 -0
- package/dist/{chunk-7GCCOT3M.js → chunk-TL7YTFLH.js} +15 -1
- package/dist/chunk-TL7YTFLH.js.map +1 -0
- package/dist/{chunk-BQTWXY6G.js → chunk-ZV2XXYR7.js} +2 -2
- package/dist/{db-BVHYI57K.js → db-P5X6UQ3E.js} +2 -2
- package/dist/{server-LUNOI26E.js → server-7QP6HQJJ.js} +556 -164
- package/dist/server-7QP6HQJJ.js.map +1 -0
- package/dist/{worker-TDJQ6TH3.js → worker-OZSWIS3F.js} +3 -3
- package/package.json +1 -1
- package/dist/chunk-7GCCOT3M.js.map +0 -1
- package/dist/chunk-AYTOCZBS.js.map +0 -1
- package/dist/chunk-DKJ2XCY7.js.map +0 -1
- package/dist/chunk-RDROUQ4E.js +0 -7
- package/dist/chunk-RDROUQ4E.js.map +0 -1
- package/dist/chunk-ROS67BNV.js.map +0 -1
- package/dist/server-LUNOI26E.js.map +0 -1
- /package/dist/{chunk-BQTWXY6G.js.map → chunk-ZV2XXYR7.js.map} +0 -0
- /package/dist/{db-BVHYI57K.js.map → db-P5X6UQ3E.js.map} +0 -0
- /package/dist/{worker-TDJQ6TH3.js.map → worker-OZSWIS3F.js.map} +0 -0
|
@@ -11,11 +11,12 @@ import {
|
|
|
11
11
|
MC_PER_CREDIT,
|
|
12
12
|
browserActiveCostMc,
|
|
13
13
|
classifyHarvestProblem,
|
|
14
|
+
concurrencySlotBillingInfo,
|
|
14
15
|
createHarvestAttemptRecorder,
|
|
15
16
|
harvestProblemResponse,
|
|
16
17
|
insufficientBalanceResponse,
|
|
17
18
|
serializeHarvestProblem
|
|
18
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-BSYPATSM.js";
|
|
19
20
|
import {
|
|
20
21
|
SiteAuditJobRowSchema,
|
|
21
22
|
cancelJob,
|
|
@@ -24,7 +25,6 @@ import {
|
|
|
24
25
|
completeJob,
|
|
25
26
|
completeWorkflowRunRecord,
|
|
26
27
|
consumePasswordResetToken,
|
|
27
|
-
countActiveJobsForUser,
|
|
28
28
|
countActiveUsers,
|
|
29
29
|
createJob,
|
|
30
30
|
createPasswordResetToken,
|
|
@@ -74,7 +74,7 @@ import {
|
|
|
74
74
|
setPassword,
|
|
75
75
|
setStripeCustomerId,
|
|
76
76
|
verifyPassword
|
|
77
|
-
} from "./chunk-
|
|
77
|
+
} from "./chunk-TL7YTFLH.js";
|
|
78
78
|
import {
|
|
79
79
|
BrowserDriver,
|
|
80
80
|
MapsPlaceOptionsSchema,
|
|
@@ -100,7 +100,7 @@ import {
|
|
|
100
100
|
harvestTimeoutBudget,
|
|
101
101
|
liveWebToolAnnotations,
|
|
102
102
|
outputBaseDir
|
|
103
|
-
} from "./chunk-
|
|
103
|
+
} from "./chunk-IWQTNY2E.js";
|
|
104
104
|
import {
|
|
105
105
|
CaptchaError,
|
|
106
106
|
RECAPTCHA_INSTRUCTIONS,
|
|
@@ -113,8 +113,8 @@ import {
|
|
|
113
113
|
runWorkflow,
|
|
114
114
|
workflowDefinition,
|
|
115
115
|
workflowOutputBaseDir
|
|
116
|
-
} from "./chunk-
|
|
117
|
-
import "./chunk-
|
|
116
|
+
} from "./chunk-F44RBOJ5.js";
|
|
117
|
+
import "./chunk-ROCXJOVY.js";
|
|
118
118
|
|
|
119
119
|
// src/api/outbound-sanitize.ts
|
|
120
120
|
var KEY_RENAMES = {
|
|
@@ -8029,6 +8029,179 @@ var YoutubeTranscribeBodySchema = z11.object({
|
|
|
8029
8029
|
videoId: z11.string().min(1, "videoId is required")
|
|
8030
8030
|
});
|
|
8031
8031
|
|
|
8032
|
+
// src/api/concurrency-gates.ts
|
|
8033
|
+
import { randomUUID } from "crypto";
|
|
8034
|
+
var DEFAULT_LOCK_TTL_SECONDS = 15 * 60;
|
|
8035
|
+
var DEFAULT_RETRY_AFTER_SECONDS = 30;
|
|
8036
|
+
var MAX_LOCK_TTL_SECONDS = 24 * 60 * 60;
|
|
8037
|
+
var schemaReady = null;
|
|
8038
|
+
function concurrencyLimitForUser(user) {
|
|
8039
|
+
return 1 + Math.max(0, Number(user.extra_concurrency_slots ?? 0));
|
|
8040
|
+
}
|
|
8041
|
+
function concurrencyLimitExceededResponse(gate) {
|
|
8042
|
+
const upgrade = concurrencySlotBillingInfo();
|
|
8043
|
+
const message = `You have ${gate.active} active operation${gate.active !== 1 ? "s" : ""}. Your account allows ${gate.limit} concurrent operation${gate.limit !== 1 ? "s" : ""}. Wait for one to finish, or add an extra concurrency slot for ${upgrade.price_label} at ${upgrade.billing_url}. Terminal upgrade: ${upgrade.terminal_command}`;
|
|
8044
|
+
return {
|
|
8045
|
+
error: message,
|
|
8046
|
+
message,
|
|
8047
|
+
error_code: "concurrency_limit_exceeded",
|
|
8048
|
+
error_type: "concurrency_limit",
|
|
8049
|
+
retryable: true,
|
|
8050
|
+
active: gate.active,
|
|
8051
|
+
limit: gate.limit,
|
|
8052
|
+
operation: gate.operation,
|
|
8053
|
+
upgrade,
|
|
8054
|
+
upgrade_url: upgrade.billing_url,
|
|
8055
|
+
upgrade_command: upgrade.terminal_command
|
|
8056
|
+
};
|
|
8057
|
+
}
|
|
8058
|
+
function isConcurrencyLimitBypassed(user) {
|
|
8059
|
+
const raw = process.env.CONCURRENCY_LIMIT_BYPASS_EMAILS ?? process.env.HARVEST_LIMIT_BYPASS_EMAILS ?? "";
|
|
8060
|
+
return raw.split(",").map((email) => email.trim()).filter(Boolean).includes(user.email);
|
|
8061
|
+
}
|
|
8062
|
+
function lockTtlModifier(ttlSeconds) {
|
|
8063
|
+
const ttl = Number.isFinite(ttlSeconds) ? Math.max(30, Math.min(Math.trunc(Number(ttlSeconds)), MAX_LOCK_TTL_SECONDS)) : DEFAULT_LOCK_TTL_SECONDS;
|
|
8064
|
+
return `+${ttl} seconds`;
|
|
8065
|
+
}
|
|
8066
|
+
function safeMetadata(metadata) {
|
|
8067
|
+
if (!metadata) return null;
|
|
8068
|
+
try {
|
|
8069
|
+
return JSON.stringify(metadata).slice(0, 4e3);
|
|
8070
|
+
} catch {
|
|
8071
|
+
return null;
|
|
8072
|
+
}
|
|
8073
|
+
}
|
|
8074
|
+
async function ensureConcurrencyGateSchema() {
|
|
8075
|
+
if (!schemaReady) {
|
|
8076
|
+
schemaReady = (async () => {
|
|
8077
|
+
const db = getDb();
|
|
8078
|
+
await db.execute(`
|
|
8079
|
+
CREATE TABLE IF NOT EXISTS concurrency_locks (
|
|
8080
|
+
id TEXT PRIMARY KEY,
|
|
8081
|
+
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
8082
|
+
operation TEXT NOT NULL,
|
|
8083
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
8084
|
+
acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
8085
|
+
expires_at TEXT NOT NULL,
|
|
8086
|
+
released_at TEXT,
|
|
8087
|
+
metadata TEXT
|
|
8088
|
+
)
|
|
8089
|
+
`);
|
|
8090
|
+
await db.execute(`CREATE INDEX IF NOT EXISTS concurrency_locks_user_status ON concurrency_locks(user_id, status, expires_at)`);
|
|
8091
|
+
await db.execute(`CREATE INDEX IF NOT EXISTS concurrency_locks_operation ON concurrency_locks(operation, status, expires_at)`);
|
|
8092
|
+
})();
|
|
8093
|
+
}
|
|
8094
|
+
return schemaReady;
|
|
8095
|
+
}
|
|
8096
|
+
async function expireConcurrencyLocksForUser(userId) {
|
|
8097
|
+
await ensureConcurrencyGateSchema();
|
|
8098
|
+
await getDb().execute({
|
|
8099
|
+
sql: `UPDATE concurrency_locks
|
|
8100
|
+
SET status = 'expired', released_at = datetime('now')
|
|
8101
|
+
WHERE user_id = ? AND status = 'active' AND expires_at <= datetime('now')`,
|
|
8102
|
+
args: [userId]
|
|
8103
|
+
});
|
|
8104
|
+
}
|
|
8105
|
+
async function countActiveUsageForUser(userId) {
|
|
8106
|
+
await ensureConcurrencyGateSchema();
|
|
8107
|
+
await expireConcurrencyLocksForUser(userId);
|
|
8108
|
+
const res = await getDb().execute({
|
|
8109
|
+
sql: `SELECT
|
|
8110
|
+
(
|
|
8111
|
+
SELECT COUNT(*)
|
|
8112
|
+
FROM jobs
|
|
8113
|
+
WHERE user_id = ?
|
|
8114
|
+
AND (status = 'pending' OR (status = 'running' AND started_at > datetime('now', '-10 minutes')))
|
|
8115
|
+
) +
|
|
8116
|
+
(
|
|
8117
|
+
SELECT COUNT(*)
|
|
8118
|
+
FROM concurrency_locks
|
|
8119
|
+
WHERE user_id = ? AND status = 'active' AND expires_at > datetime('now')
|
|
8120
|
+
) AS n`,
|
|
8121
|
+
args: [userId, userId]
|
|
8122
|
+
});
|
|
8123
|
+
return Number(res.rows[0]?.n ?? 0);
|
|
8124
|
+
}
|
|
8125
|
+
async function reuseExistingConcurrencyGate(userId, lockId, ttlSeconds) {
|
|
8126
|
+
if (!/^cl_[a-f0-9]{24,32}$/.test(lockId)) return false;
|
|
8127
|
+
await ensureConcurrencyGateSchema();
|
|
8128
|
+
await expireConcurrencyLocksForUser(userId);
|
|
8129
|
+
const res = await getDb().execute({
|
|
8130
|
+
sql: `UPDATE concurrency_locks
|
|
8131
|
+
SET expires_at = datetime('now', ?)
|
|
8132
|
+
WHERE id = ?
|
|
8133
|
+
AND user_id = ?
|
|
8134
|
+
AND status = 'active'
|
|
8135
|
+
AND expires_at > datetime('now')`,
|
|
8136
|
+
args: [lockTtlModifier(ttlSeconds), lockId, userId]
|
|
8137
|
+
});
|
|
8138
|
+
return Number(res.rowsAffected ?? 0) > 0;
|
|
8139
|
+
}
|
|
8140
|
+
async function acquireConcurrencyGate(user, operation, options = {}) {
|
|
8141
|
+
await ensureConcurrencyGateSchema();
|
|
8142
|
+
const limit = concurrencyLimitForUser(user);
|
|
8143
|
+
if (isConcurrencyLimitBypassed(user)) {
|
|
8144
|
+
return { ok: true, lockId: null, active: 0, limit, operation, bypassed: true };
|
|
8145
|
+
}
|
|
8146
|
+
if (options.reuseLockId && await reuseExistingConcurrencyGate(user.id, options.reuseLockId, options.ttlSeconds)) {
|
|
8147
|
+
return { ok: true, lockId: null, active: await countActiveUsageForUser(user.id), limit, operation, reused: true };
|
|
8148
|
+
}
|
|
8149
|
+
await expireConcurrencyLocksForUser(user.id);
|
|
8150
|
+
const lockId = `cl_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
|
|
8151
|
+
const res = await getDb().execute({
|
|
8152
|
+
sql: `INSERT INTO concurrency_locks (id, user_id, operation, status, expires_at, metadata)
|
|
8153
|
+
SELECT ?, ?, ?, 'active', datetime('now', ?), ?
|
|
8154
|
+
WHERE (
|
|
8155
|
+
(
|
|
8156
|
+
SELECT COUNT(*)
|
|
8157
|
+
FROM jobs
|
|
8158
|
+
WHERE user_id = ?
|
|
8159
|
+
AND (status = 'pending' OR (status = 'running' AND started_at > datetime('now', '-10 minutes')))
|
|
8160
|
+
) +
|
|
8161
|
+
(
|
|
8162
|
+
SELECT COUNT(*)
|
|
8163
|
+
FROM concurrency_locks
|
|
8164
|
+
WHERE user_id = ? AND status = 'active' AND expires_at > datetime('now')
|
|
8165
|
+
)
|
|
8166
|
+
) < ?`,
|
|
8167
|
+
args: [
|
|
8168
|
+
lockId,
|
|
8169
|
+
user.id,
|
|
8170
|
+
operation,
|
|
8171
|
+
lockTtlModifier(options.ttlSeconds),
|
|
8172
|
+
safeMetadata(options.metadata),
|
|
8173
|
+
user.id,
|
|
8174
|
+
user.id,
|
|
8175
|
+
limit
|
|
8176
|
+
]
|
|
8177
|
+
});
|
|
8178
|
+
const active = await countActiveUsageForUser(user.id);
|
|
8179
|
+
if (Number(res.rowsAffected ?? 0) > 0) {
|
|
8180
|
+
return { ok: true, lockId, active, limit, operation };
|
|
8181
|
+
}
|
|
8182
|
+
return { ok: false, active, limit, operation, retryAfterSeconds: DEFAULT_RETRY_AFTER_SECONDS };
|
|
8183
|
+
}
|
|
8184
|
+
async function releaseConcurrencyGate(lockId) {
|
|
8185
|
+
if (!lockId) return;
|
|
8186
|
+
await ensureConcurrencyGateSchema();
|
|
8187
|
+
await getDb().execute({
|
|
8188
|
+
sql: `UPDATE concurrency_locks
|
|
8189
|
+
SET status = 'released', released_at = datetime('now')
|
|
8190
|
+
WHERE id = ? AND status = 'active'`,
|
|
8191
|
+
args: [lockId]
|
|
8192
|
+
});
|
|
8193
|
+
}
|
|
8194
|
+
async function extendConcurrencyGate(lockId, ttlSeconds = DEFAULT_LOCK_TTL_SECONDS) {
|
|
8195
|
+
if (!lockId) return;
|
|
8196
|
+
await ensureConcurrencyGateSchema();
|
|
8197
|
+
await getDb().execute({
|
|
8198
|
+
sql: `UPDATE concurrency_locks
|
|
8199
|
+
SET expires_at = datetime('now', ?)
|
|
8200
|
+
WHERE id = ? AND status = 'active' AND expires_at > datetime('now')`,
|
|
8201
|
+
args: [lockTtlModifier(ttlSeconds), lockId]
|
|
8202
|
+
});
|
|
8203
|
+
}
|
|
8204
|
+
|
|
8032
8205
|
// src/api/youtube-routes.ts
|
|
8033
8206
|
var youtubeApp = new Hono2();
|
|
8034
8207
|
youtubeApp.post("/harvest", createApiKeyAuth(), async (c) => {
|
|
@@ -8043,9 +8216,16 @@ youtubeApp.post("/harvest", createApiKeyAuth(), async (c) => {
|
|
|
8043
8216
|
return c.json({ error: "channel mode requires channelHandle (@handle or UC... ID)" }, 400);
|
|
8044
8217
|
}
|
|
8045
8218
|
const user = c.get("user");
|
|
8046
|
-
const
|
|
8047
|
-
|
|
8219
|
+
const gate = await acquireConcurrencyGate(user, "youtube_harvest", {
|
|
8220
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
8221
|
+
metadata: { mode, query, channelHandle }
|
|
8222
|
+
});
|
|
8223
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
8224
|
+
let debited = false;
|
|
8048
8225
|
try {
|
|
8226
|
+
const { ok: ytOk, balance_mc: ytBal } = await debitMc(user.id, MC_COSTS.yt_channel, LedgerOperation.YT_CHANNEL, query ?? channelHandle ?? "");
|
|
8227
|
+
if (!ytOk) return c.json(insufficientBalanceResponse(ytBal, MC_COSTS.yt_channel), 402);
|
|
8228
|
+
debited = true;
|
|
8049
8229
|
const normalizedChannelUrl = mode === "channel" ? buildYouTubeChannelVideosUrl(channelHandle.trim()) : void 0;
|
|
8050
8230
|
const result = await ytHarvest({
|
|
8051
8231
|
mode,
|
|
@@ -8070,13 +8250,15 @@ youtubeApp.post("/harvest", createApiKeyAuth(), async (c) => {
|
|
|
8070
8250
|
return c.json({ error: msg }, 400);
|
|
8071
8251
|
}
|
|
8072
8252
|
if (err instanceof CaptchaError || msg.includes("CAPTCHA") || msg.includes("blocked")) {
|
|
8073
|
-
await creditMc(user.id, MC_COSTS.yt_channel, LedgerOperation.YT_CHANNEL_REFUND, "failed call");
|
|
8253
|
+
if (debited) await creditMc(user.id, MC_COSTS.yt_channel, LedgerOperation.YT_CHANNEL_REFUND, "failed call");
|
|
8074
8254
|
await logRequestEvent({ userId: user.id, source: "youtube_harvest", status: "failed", query: mode === "search" ? query?.trim() ?? "" : channelHandle?.trim() ?? "", error: msg });
|
|
8075
8255
|
return c.json({ error: msg }, 503);
|
|
8076
8256
|
}
|
|
8077
|
-
await creditMc(user.id, MC_COSTS.yt_channel, LedgerOperation.YT_CHANNEL_REFUND, "failed call");
|
|
8257
|
+
if (debited) await creditMc(user.id, MC_COSTS.yt_channel, LedgerOperation.YT_CHANNEL_REFUND, "failed call");
|
|
8078
8258
|
await logRequestEvent({ userId: user.id, source: "youtube_harvest", status: "failed", query: mode === "search" ? query?.trim() ?? "" : channelHandle?.trim() ?? "", error: msg });
|
|
8079
8259
|
return c.json({ error: msg }, 500);
|
|
8260
|
+
} finally {
|
|
8261
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
8080
8262
|
}
|
|
8081
8263
|
});
|
|
8082
8264
|
youtubeApp.post("/transcribe", createApiKeyAuth(), async (c) => {
|
|
@@ -8088,9 +8270,16 @@ youtubeApp.post("/transcribe", createApiKeyAuth(), async (c) => {
|
|
|
8088
8270
|
const user = c.get("user");
|
|
8089
8271
|
const holdMins = 5;
|
|
8090
8272
|
const holdMc = MC_COSTS.yt_transcription * holdMins;
|
|
8091
|
-
const
|
|
8092
|
-
|
|
8273
|
+
const gate = await acquireConcurrencyGate(user, "youtube_transcribe", {
|
|
8274
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
8275
|
+
metadata: { videoId: id }
|
|
8276
|
+
});
|
|
8277
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
8278
|
+
let debited = false;
|
|
8093
8279
|
try {
|
|
8280
|
+
const { ok: trOk, balance_mc: trBal } = await debitMc(user.id, holdMc, LedgerOperation.TRANSCRIPTION_HOLD, id);
|
|
8281
|
+
if (!trOk) return c.json(insufficientBalanceResponse(trBal, holdMc), 402);
|
|
8282
|
+
debited = true;
|
|
8094
8283
|
const result = await fetchCaptions(id);
|
|
8095
8284
|
const lastChunk = result.chunks?.[result.chunks.length - 1];
|
|
8096
8285
|
const chunkSecs = lastChunk && Number.isFinite(lastChunk.timestamp[1]) ? lastChunk.timestamp[1] : 0;
|
|
@@ -8115,10 +8304,12 @@ youtubeApp.post("/transcribe", createApiKeyAuth(), async (c) => {
|
|
|
8115
8304
|
html: buildTranscriptHtml(result)
|
|
8116
8305
|
});
|
|
8117
8306
|
} catch (err) {
|
|
8118
|
-
await creditMc(user.id, holdMc, LedgerOperation.TRANSCRIPTION_REFUND, "failed call");
|
|
8307
|
+
if (debited) await creditMc(user.id, holdMc, LedgerOperation.TRANSCRIPTION_REFUND, "failed call");
|
|
8119
8308
|
const msg = err instanceof Error ? err.message : String(err);
|
|
8120
8309
|
await logRequestEvent({ userId: user.id, source: "youtube_transcribe", status: "failed", query: id, error: msg });
|
|
8121
8310
|
return c.json({ error: msg }, 500);
|
|
8311
|
+
} finally {
|
|
8312
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
8122
8313
|
}
|
|
8123
8314
|
});
|
|
8124
8315
|
function buildTranscriptMarkdown(result) {
|
|
@@ -8187,6 +8378,7 @@ screenshotApp.post("/", async (c) => {
|
|
|
8187
8378
|
}
|
|
8188
8379
|
const body = parsed.data;
|
|
8189
8380
|
const urlCheck = await validatePublicHttpUrl(body.url, { field: "url", requireHttps: false });
|
|
8381
|
+
let targetUrl = urlCheck.parsed?.href;
|
|
8190
8382
|
if (urlCheck.error) {
|
|
8191
8383
|
if (!body.allowLocal) {
|
|
8192
8384
|
return c.json({ error_code: "invalid_request", message: urlCheck.error }, 400);
|
|
@@ -8200,25 +8392,17 @@ screenshotApp.post("/", async (c) => {
|
|
|
8200
8392
|
if (parsedFallback.protocol !== "http:" && parsedFallback.protocol !== "https:") {
|
|
8201
8393
|
return c.json({ error: "url must use http or https" }, 400);
|
|
8202
8394
|
}
|
|
8203
|
-
|
|
8204
|
-
try {
|
|
8205
|
-
const buf = await captureScreenshot(parsedFallback.href, browserServiceApiKey(), device2);
|
|
8206
|
-
return new Response(new Uint8Array(buf), {
|
|
8207
|
-
status: 200,
|
|
8208
|
-
headers: {
|
|
8209
|
-
"Content-Type": "image/png",
|
|
8210
|
-
"Content-Length": String(buf.length),
|
|
8211
|
-
"Cache-Control": "no-store"
|
|
8212
|
-
}
|
|
8213
|
-
});
|
|
8214
|
-
} catch (err) {
|
|
8215
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
8216
|
-
return c.json({ error: msg }, 502);
|
|
8217
|
-
}
|
|
8395
|
+
targetUrl = parsedFallback.href;
|
|
8218
8396
|
}
|
|
8219
8397
|
const device = body.device === "mobile" ? "mobile" : "desktop";
|
|
8398
|
+
const user = c.get("user");
|
|
8399
|
+
const gate = await acquireConcurrencyGate(user, "screenshot", {
|
|
8400
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
8401
|
+
metadata: { url: targetUrl, device }
|
|
8402
|
+
});
|
|
8403
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
8220
8404
|
try {
|
|
8221
|
-
const buf = await captureScreenshot(
|
|
8405
|
+
const buf = await captureScreenshot(targetUrl, browserServiceApiKey(), device);
|
|
8222
8406
|
return new Response(new Uint8Array(buf), {
|
|
8223
8407
|
status: 200,
|
|
8224
8408
|
headers: {
|
|
@@ -8230,6 +8414,8 @@ screenshotApp.post("/", async (c) => {
|
|
|
8230
8414
|
} catch (err) {
|
|
8231
8415
|
const msg = err instanceof Error ? err.message : String(err);
|
|
8232
8416
|
return c.json({ error: msg }, 502);
|
|
8417
|
+
} finally {
|
|
8418
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
8233
8419
|
}
|
|
8234
8420
|
});
|
|
8235
8421
|
|
|
@@ -9075,10 +9261,17 @@ facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
|
|
|
9075
9261
|
const libraryId = FacebookAdExtractor.resolveLibraryId(raw2);
|
|
9076
9262
|
if (!libraryId) return c.json({ error: "Could not resolve a valid Facebook Ad Library ID from the provided input" }, 400);
|
|
9077
9263
|
const fbUser = c.get("user");
|
|
9078
|
-
const
|
|
9079
|
-
|
|
9264
|
+
const gate = await acquireConcurrencyGate(fbUser, "facebook_ad", {
|
|
9265
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
9266
|
+
metadata: { libraryId }
|
|
9267
|
+
});
|
|
9268
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
9080
9269
|
const driver = new BrowserDriver();
|
|
9270
|
+
let debited = false;
|
|
9081
9271
|
try {
|
|
9272
|
+
const { ok: adOk, balance_mc: adBal } = await debitMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD, raw2);
|
|
9273
|
+
if (!adOk) return c.json(insufficientBalanceResponse(adBal, MC_COSTS.fb_ad), 402);
|
|
9274
|
+
debited = true;
|
|
9082
9275
|
await driver.launch(kernelLaunchOpts());
|
|
9083
9276
|
const extractor = new FacebookAdExtractor(driver);
|
|
9084
9277
|
const result = await extractor.extract(libraryId, { openModal: body.openModal !== false });
|
|
@@ -9092,7 +9285,7 @@ facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
|
|
|
9092
9285
|
});
|
|
9093
9286
|
return c.json(result);
|
|
9094
9287
|
} catch (err) {
|
|
9095
|
-
await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
|
|
9288
|
+
if (debited) await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
|
|
9096
9289
|
const msg = err instanceof Error ? err.message : String(err);
|
|
9097
9290
|
await logRequestEvent({ userId: fbUser.id, source: "facebook_ad", status: "failed", query: raw2, error: msg });
|
|
9098
9291
|
if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
|
|
@@ -9101,24 +9294,26 @@ facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
|
|
|
9101
9294
|
return c.json({ error: msg }, 500);
|
|
9102
9295
|
} finally {
|
|
9103
9296
|
await driver.close();
|
|
9297
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
9104
9298
|
}
|
|
9105
9299
|
});
|
|
9106
9300
|
facebookAdApp.post("/page-intel", createApiKeyAuth(), async (c) => {
|
|
9107
9301
|
const raw = await c.req.json().catch(() => ({}));
|
|
9108
9302
|
const parsed = FacebookPageIntelBodySchema.safeParse(raw);
|
|
9109
|
-
if (!parsed.success)
|
|
9110
|
-
return c.json(invalidRequest(parsed.error.issues[0]?.message ?? "Invalid request"), 400);
|
|
9111
|
-
}
|
|
9303
|
+
if (!parsed.success) return c.json(invalidRequest(parsed.error.issues[0]?.message ?? "Invalid request"), 400);
|
|
9112
9304
|
const body = parsed.data;
|
|
9113
9305
|
const maxAds = Math.min(200, Math.max(1, body.maxAds ?? 50));
|
|
9114
9306
|
const country = body.country?.trim().toUpperCase() ?? "US";
|
|
9115
9307
|
const listingUrl = buildPageIntelUrl(body, country);
|
|
9116
9308
|
const fbUser = c.get("user");
|
|
9117
|
-
const
|
|
9118
|
-
if (!
|
|
9309
|
+
const gate = await acquireConcurrencyGate(fbUser, "facebook_page_intel", { reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"), metadata: { pageId: body.pageId, query: body.query, libraryId: body.libraryId, country } });
|
|
9310
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
9119
9311
|
const driver = new BrowserDriver();
|
|
9120
|
-
let refunded = false;
|
|
9312
|
+
let refunded = false, debited = false;
|
|
9121
9313
|
try {
|
|
9314
|
+
const { ok: fbOk, balance_mc: fbBal } = await debitMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD, body.pageId ?? body.query ?? body.libraryId ?? "");
|
|
9315
|
+
if (!fbOk) return c.json(insufficientBalanceResponse(fbBal, MC_COSTS.fb_ad), 402);
|
|
9316
|
+
debited = true;
|
|
9122
9317
|
await driver.launch(await kernelLaunchOptsResidential());
|
|
9123
9318
|
await driver.navigateTo(listingUrl);
|
|
9124
9319
|
const extractor = new FacebookAdExtractor(driver);
|
|
@@ -9133,7 +9328,7 @@ facebookAdApp.post("/page-intel", createApiKeyAuth(), async (c) => {
|
|
|
9133
9328
|
return c.json(result);
|
|
9134
9329
|
} catch (err) {
|
|
9135
9330
|
const msg = err instanceof Error ? err.message : String(err);
|
|
9136
|
-
if (!refunded) await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
|
|
9331
|
+
if (debited && !refunded) await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
|
|
9137
9332
|
await logRequestEvent({ userId: fbUser.id, source: "facebook_page_intel", status: "failed", query: body.pageId ?? body.query ?? body.libraryId ?? "", error: msg });
|
|
9138
9333
|
if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
|
|
9139
9334
|
return c.json({ error: msg }, 503);
|
|
@@ -9141,6 +9336,7 @@ facebookAdApp.post("/page-intel", createApiKeyAuth(), async (c) => {
|
|
|
9141
9336
|
return c.json({ error: msg }, 500);
|
|
9142
9337
|
} finally {
|
|
9143
9338
|
await driver.close();
|
|
9339
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
9144
9340
|
}
|
|
9145
9341
|
});
|
|
9146
9342
|
facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
|
|
@@ -9156,10 +9352,17 @@ facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
|
|
|
9156
9352
|
}
|
|
9157
9353
|
const videoUrl = urlCheck.parsed.href;
|
|
9158
9354
|
const fbUser = c.get("user");
|
|
9159
|
-
const
|
|
9160
|
-
|
|
9355
|
+
const gate = await acquireConcurrencyGate(fbUser, "facebook_transcribe", {
|
|
9356
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
9357
|
+
metadata: { videoUrl }
|
|
9358
|
+
});
|
|
9359
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
9161
9360
|
fal2.config({ credentials: process.env.FAL_KEY });
|
|
9361
|
+
let debited = false;
|
|
9162
9362
|
try {
|
|
9363
|
+
const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE, videoUrl);
|
|
9364
|
+
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_transcribe), 402);
|
|
9365
|
+
debited = true;
|
|
9163
9366
|
const startMs = Date.now();
|
|
9164
9367
|
const result = await fal2.subscribe("fal-ai/wizper", {
|
|
9165
9368
|
input: { audio_url: videoUrl, task: "transcribe", language: "en" },
|
|
@@ -9182,9 +9385,11 @@ facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
|
|
|
9182
9385
|
return c.json({ text, chunks, durationMs, markdown: lines.join("\n") });
|
|
9183
9386
|
} catch (err) {
|
|
9184
9387
|
const msg = err instanceof Error ? err.message : String(err);
|
|
9185
|
-
await creditMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE_REFUND, "failed call");
|
|
9388
|
+
if (debited) await creditMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE_REFUND, "failed call");
|
|
9186
9389
|
await logRequestEvent({ userId: fbUser.id, source: "facebook_transcribe", status: "failed", query: videoUrl, error: msg });
|
|
9187
9390
|
return c.json({ error: msg }, 500);
|
|
9391
|
+
} finally {
|
|
9392
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
9188
9393
|
}
|
|
9189
9394
|
});
|
|
9190
9395
|
facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
@@ -9198,11 +9403,18 @@ facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
|
9198
9403
|
const maxResults = Math.min(20, Math.max(1, body.maxResults ?? 10));
|
|
9199
9404
|
const searchUrl = `https://www.facebook.com/ads/library/?active_status=all&ad_type=all&country=${country}&q=${encodeURIComponent(body.query.trim())}&search_type=keyword_unordered`;
|
|
9200
9405
|
const fbUser = c.get("user");
|
|
9201
|
-
const
|
|
9202
|
-
|
|
9406
|
+
const gate = await acquireConcurrencyGate(fbUser, "facebook_search", {
|
|
9407
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
9408
|
+
metadata: { query: body.query.trim(), country }
|
|
9409
|
+
});
|
|
9410
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
9203
9411
|
const driver = new BrowserDriver();
|
|
9204
9412
|
let searchRefunded = false;
|
|
9413
|
+
let debited = false;
|
|
9205
9414
|
try {
|
|
9415
|
+
const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH, body.query.trim());
|
|
9416
|
+
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_search), 402);
|
|
9417
|
+
debited = true;
|
|
9206
9418
|
await driver.launch(await kernelLaunchOptsResidential());
|
|
9207
9419
|
const page = driver.getPage();
|
|
9208
9420
|
const collated = await collectAdLibraryResults(page, searchUrl, Math.max(maxResults * 4, 40));
|
|
@@ -9263,7 +9475,7 @@ facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
|
9263
9475
|
return c.json(searchResult);
|
|
9264
9476
|
} catch (err) {
|
|
9265
9477
|
const msg = err instanceof Error ? err.message : String(err);
|
|
9266
|
-
if (!searchRefunded) await creditMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH_REFUND, "failed call");
|
|
9478
|
+
if (debited && !searchRefunded) await creditMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH_REFUND, "failed call");
|
|
9267
9479
|
await logRequestEvent({ userId: fbUser.id, source: "facebook_search", status: "failed", query: body.query.trim(), error: msg });
|
|
9268
9480
|
if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
|
|
9269
9481
|
return c.json({ error: msg }, 503);
|
|
@@ -9271,6 +9483,7 @@ facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
|
9271
9483
|
return c.json({ error: msg }, 500);
|
|
9272
9484
|
} finally {
|
|
9273
9485
|
await driver.close();
|
|
9486
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
9274
9487
|
}
|
|
9275
9488
|
});
|
|
9276
9489
|
var ALLOWED_MEDIA_HOSTS = ["fbcdn.net", "cdninstagram.com", "scontent.facebook.com", "scontent.cdninstagram.com"];
|
|
@@ -10129,14 +10342,21 @@ mapsApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
|
10129
10342
|
if (!parsed.success) {
|
|
10130
10343
|
return c.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, 400);
|
|
10131
10344
|
}
|
|
10132
|
-
const
|
|
10133
|
-
|
|
10134
|
-
|
|
10135
|
-
|
|
10136
|
-
|
|
10137
|
-
|
|
10138
|
-
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_search), 402);
|
|
10345
|
+
const gate = await acquireConcurrencyGate(user, "maps_search", {
|
|
10346
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
10347
|
+
metadata: { query: parsed.data.query, location: parsed.data.location }
|
|
10348
|
+
});
|
|
10349
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
10350
|
+
let debited = false;
|
|
10139
10351
|
try {
|
|
10352
|
+
const { ok, balance_mc } = await debitMc(
|
|
10353
|
+
user.id,
|
|
10354
|
+
MC_COSTS.maps_search,
|
|
10355
|
+
LedgerOperation.MAPS_SEARCH,
|
|
10356
|
+
[parsed.data.query, parsed.data.location].filter(Boolean).join(" ")
|
|
10357
|
+
);
|
|
10358
|
+
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_search), 402);
|
|
10359
|
+
debited = true;
|
|
10140
10360
|
const result = await runMapsSearchWithRotation({
|
|
10141
10361
|
...parsed.data,
|
|
10142
10362
|
configuredKernelProxyId: browserServiceProxyId()
|
|
@@ -10152,7 +10372,7 @@ mapsApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
|
10152
10372
|
});
|
|
10153
10373
|
return c.json(result);
|
|
10154
10374
|
} catch (err) {
|
|
10155
|
-
await creditMc(user.id, MC_COSTS.maps_search, LedgerOperation.REFUND, "failed maps_search call");
|
|
10375
|
+
if (debited) await creditMc(user.id, MC_COSTS.maps_search, LedgerOperation.REFUND, "failed maps_search call");
|
|
10156
10376
|
const msg = err instanceof Error ? err.message : String(err);
|
|
10157
10377
|
await logRequestEvent({
|
|
10158
10378
|
userId: user.id,
|
|
@@ -10164,6 +10384,8 @@ mapsApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
|
10164
10384
|
result: err instanceof MapsSearchRotationError ? { attempts: err.attempts } : void 0
|
|
10165
10385
|
});
|
|
10166
10386
|
return mapsErrorResponse(c, err, "maps_search_failed");
|
|
10387
|
+
} finally {
|
|
10388
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
10167
10389
|
}
|
|
10168
10390
|
});
|
|
10169
10391
|
mapsApp.post("/place", createApiKeyAuth(), async (c) => {
|
|
@@ -10176,17 +10398,24 @@ mapsApp.post("/place", createApiKeyAuth(), async (c) => {
|
|
|
10176
10398
|
if (!parsed.success) {
|
|
10177
10399
|
return c.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, 400);
|
|
10178
10400
|
}
|
|
10179
|
-
const
|
|
10180
|
-
|
|
10181
|
-
|
|
10182
|
-
|
|
10183
|
-
|
|
10184
|
-
);
|
|
10185
|
-
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_place), 402);
|
|
10401
|
+
const gate = await acquireConcurrencyGate(user, "maps_place", {
|
|
10402
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
10403
|
+
metadata: { businessName: parsed.data.businessName, location: parsed.data.location }
|
|
10404
|
+
});
|
|
10405
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
10186
10406
|
const driver = new BrowserDriver();
|
|
10187
10407
|
const extractor = new MapsExtractor(driver);
|
|
10188
10408
|
let reviewDebitMc = 0;
|
|
10409
|
+
let debited = false;
|
|
10189
10410
|
try {
|
|
10411
|
+
const { ok, balance_mc } = await debitMc(
|
|
10412
|
+
user.id,
|
|
10413
|
+
MC_COSTS.maps_place,
|
|
10414
|
+
LedgerOperation.MAPS_PLACE,
|
|
10415
|
+
`${parsed.data.businessName} ${parsed.data.location}`
|
|
10416
|
+
);
|
|
10417
|
+
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_place), 402);
|
|
10418
|
+
debited = true;
|
|
10190
10419
|
const result = await extractor.extract(parsed.data);
|
|
10191
10420
|
const reviewCount = Array.isArray(result.reviews) ? result.reviews.length : 0;
|
|
10192
10421
|
if (reviewCount > 0) {
|
|
@@ -10214,7 +10443,7 @@ mapsApp.post("/place", createApiKeyAuth(), async (c) => {
|
|
|
10214
10443
|
});
|
|
10215
10444
|
return c.json(result);
|
|
10216
10445
|
} catch (err) {
|
|
10217
|
-
await creditMc(user.id, MC_COSTS.maps_place, LedgerOperation.REFUND, "failed maps_place call");
|
|
10446
|
+
if (debited) await creditMc(user.id, MC_COSTS.maps_place, LedgerOperation.REFUND, "failed maps_place call");
|
|
10218
10447
|
if (reviewDebitMc > 0) {
|
|
10219
10448
|
await creditMc(
|
|
10220
10449
|
user.id,
|
|
@@ -10235,6 +10464,7 @@ mapsApp.post("/place", createApiKeyAuth(), async (c) => {
|
|
|
10235
10464
|
return mapsErrorResponse(c, msg, "maps_place_failed");
|
|
10236
10465
|
} finally {
|
|
10237
10466
|
await driver.close();
|
|
10467
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
10238
10468
|
}
|
|
10239
10469
|
});
|
|
10240
10470
|
|
|
@@ -10658,18 +10888,27 @@ directoryApp.post("/run", createApiKeyAuth(), async (c) => {
|
|
|
10658
10888
|
if (!kernelApiKey && parsed.data.proxyMode !== "none") {
|
|
10659
10889
|
return c.json({ error: "Browser service API key is required for directory workflow Maps searches unless proxyMode is none" }, 503);
|
|
10660
10890
|
}
|
|
10661
|
-
const
|
|
10662
|
-
|
|
10663
|
-
|
|
10664
|
-
|
|
10665
|
-
|
|
10666
|
-
|
|
10667
|
-
|
|
10668
|
-
|
|
10669
|
-
);
|
|
10670
|
-
if (!debit.ok) return c.json(insufficientBalanceResponse(debit.balance_mc, requiredMc), 402);
|
|
10671
|
-
}
|
|
10891
|
+
const gate = await acquireConcurrencyGate(user, "directory_workflow", {
|
|
10892
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
10893
|
+
ttlSeconds: 30 * 60,
|
|
10894
|
+
metadata: { query: parsed.data.query, state: parsed.data.state }
|
|
10895
|
+
});
|
|
10896
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
10897
|
+
let debited = false;
|
|
10898
|
+
let requiredMc = 0;
|
|
10672
10899
|
try {
|
|
10900
|
+
const plan = await resolveDirectoryMarkets(parsed.data);
|
|
10901
|
+
requiredMc = plan.markets.length * MC_COSTS.maps_search;
|
|
10902
|
+
if (requiredMc > 0) {
|
|
10903
|
+
const debit = await debitMc(
|
|
10904
|
+
user.id,
|
|
10905
|
+
requiredMc,
|
|
10906
|
+
LedgerOperation.MAPS_SEARCH,
|
|
10907
|
+
`directory_workflow ${parsed.data.query} ${parsed.data.state} ${plan.markets.length} cities`
|
|
10908
|
+
);
|
|
10909
|
+
if (!debit.ok) return c.json(insufficientBalanceResponse(debit.balance_mc, requiredMc), 402);
|
|
10910
|
+
debited = true;
|
|
10911
|
+
}
|
|
10673
10912
|
const result = await runDirectoryWorkflowFromPlan(parsed.data, plan);
|
|
10674
10913
|
const failedCities = result.cities.filter((city) => city.status === "failed").length;
|
|
10675
10914
|
if (failedCities > 0) {
|
|
@@ -10686,7 +10925,7 @@ directoryApp.post("/run", createApiKeyAuth(), async (c) => {
|
|
|
10686
10925
|
});
|
|
10687
10926
|
return c.json(result);
|
|
10688
10927
|
} catch (err) {
|
|
10689
|
-
if (requiredMc > 0) {
|
|
10928
|
+
if (debited && requiredMc > 0) {
|
|
10690
10929
|
await creditMc(user.id, requiredMc, LedgerOperation.REFUND, "failed directory_workflow call");
|
|
10691
10930
|
}
|
|
10692
10931
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -10699,6 +10938,8 @@ directoryApp.post("/run", createApiKeyAuth(), async (c) => {
|
|
|
10699
10938
|
error: message
|
|
10700
10939
|
});
|
|
10701
10940
|
return c.json({ error: message, error_code: "directory_workflow_failed", retryable: true }, 500);
|
|
10941
|
+
} finally {
|
|
10942
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
10702
10943
|
}
|
|
10703
10944
|
});
|
|
10704
10945
|
|
|
@@ -10845,7 +11086,8 @@ async function executeWorkflowRun(input) {
|
|
|
10845
11086
|
apiKey: input.user.api_key,
|
|
10846
11087
|
apiUrl: input.apiUrl,
|
|
10847
11088
|
outputDir: hostedWorkflowOutputDir(),
|
|
10848
|
-
runId: input.runId
|
|
11089
|
+
runId: input.runId,
|
|
11090
|
+
headers: input.concurrencyLockId ? { "x-mcp-scraper-concurrency-lock": input.concurrencyLockId } : void 0
|
|
10849
11091
|
});
|
|
10850
11092
|
const manifest = await readManifestFromSummary(summary);
|
|
10851
11093
|
await completeWorkflowRunRecord(input.runId, summary.status, manifest ?? { summary });
|
|
@@ -10878,20 +11120,29 @@ workflowApp.post("/run", createApiKeyAuth(), async (c) => {
|
|
|
10878
11120
|
} catch (err) {
|
|
10879
11121
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
|
|
10880
11122
|
}
|
|
10881
|
-
const
|
|
11123
|
+
const gate = await acquireConcurrencyGate(user, "workflow_run", {
|
|
11124
|
+
ttlSeconds: 60 * 60,
|
|
11125
|
+
metadata: { workflowId: parsed.data.workflowId }
|
|
11126
|
+
});
|
|
11127
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
11128
|
+
let run = null;
|
|
10882
11129
|
try {
|
|
11130
|
+
run = await createWorkflowRun({ userId: user.id, workflowId: parsed.data.workflowId, workflowInput: input });
|
|
10883
11131
|
const executed = await executeWorkflowRun({
|
|
10884
11132
|
runId: run.id,
|
|
10885
11133
|
user,
|
|
10886
11134
|
workflowId: parsed.data.workflowId,
|
|
10887
11135
|
workflowInput: input,
|
|
10888
11136
|
apiUrl: originFromUrl(c.req.url),
|
|
10889
|
-
webhookUrl
|
|
11137
|
+
webhookUrl,
|
|
11138
|
+
concurrencyLockId: gate.lockId
|
|
10890
11139
|
});
|
|
10891
11140
|
return c.json({ run: exposeRun(c, executed.run, executed.artifacts), summary: executed.summary });
|
|
10892
11141
|
} catch (err) {
|
|
10893
|
-
const failed = await getWorkflowRun(run.id, user.id);
|
|
11142
|
+
const failed = run ? await getWorkflowRun(run.id, user.id) : null;
|
|
10894
11143
|
return c.json({ run: failed, error: err instanceof Error ? err.message : String(err) }, 500);
|
|
11144
|
+
} finally {
|
|
11145
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
10895
11146
|
}
|
|
10896
11147
|
});
|
|
10897
11148
|
workflowApp.get("/runs", createApiKeyAuth(), async (c) => {
|
|
@@ -10989,8 +11240,14 @@ workflowApp.post("/schedules/:id/run", createApiKeyAuth(), async (c) => {
|
|
|
10989
11240
|
const user = c.get("user");
|
|
10990
11241
|
const schedule = await getWorkflowSchedule(c.req.param("id"), user.id);
|
|
10991
11242
|
if (!schedule) return c.json({ error: "Schedule not found" }, 404);
|
|
10992
|
-
const
|
|
11243
|
+
const gate = await acquireConcurrencyGate(user, "workflow_schedule_run", {
|
|
11244
|
+
ttlSeconds: 60 * 60,
|
|
11245
|
+
metadata: { scheduleId: schedule.id, workflowId: schedule.workflow_id }
|
|
11246
|
+
});
|
|
11247
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
11248
|
+
let run = null;
|
|
10993
11249
|
try {
|
|
11250
|
+
run = await createWorkflowRun({ userId: user.id, scheduleId: schedule.id, workflowId: schedule.workflow_id, workflowInput: schedule.input });
|
|
10994
11251
|
const executed = await executeWorkflowRun({
|
|
10995
11252
|
runId: run.id,
|
|
10996
11253
|
user,
|
|
@@ -10998,12 +11255,15 @@ workflowApp.post("/schedules/:id/run", createApiKeyAuth(), async (c) => {
|
|
|
10998
11255
|
workflowInput: schedule.input,
|
|
10999
11256
|
apiUrl: originFromUrl(c.req.url),
|
|
11000
11257
|
scheduleId: schedule.id,
|
|
11001
|
-
webhookUrl: schedule.webhook_url
|
|
11258
|
+
webhookUrl: schedule.webhook_url,
|
|
11259
|
+
concurrencyLockId: gate.lockId
|
|
11002
11260
|
});
|
|
11003
11261
|
return c.json({ run: exposeRun(c, executed.run, executed.artifacts), summary: executed.summary });
|
|
11004
11262
|
} catch (err) {
|
|
11005
|
-
const failed = await getWorkflowRun(run.id, user.id);
|
|
11263
|
+
const failed = run ? await getWorkflowRun(run.id, user.id) : null;
|
|
11006
11264
|
return c.json({ run: failed, error: err instanceof Error ? err.message : String(err) }, 500);
|
|
11265
|
+
} finally {
|
|
11266
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
11007
11267
|
}
|
|
11008
11268
|
});
|
|
11009
11269
|
async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
|
|
@@ -11017,19 +11277,28 @@ async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
|
|
|
11017
11277
|
continue;
|
|
11018
11278
|
}
|
|
11019
11279
|
const scheduledFor = schedule.next_run_at ?? now;
|
|
11020
|
-
const
|
|
11021
|
-
|
|
11022
|
-
scheduleId: schedule.id,
|
|
11023
|
-
workflowId: schedule.workflow_id,
|
|
11024
|
-
workflowInput: schedule.input,
|
|
11025
|
-
idempotencyKey: `${schedule.id}:${scheduledFor}`
|
|
11280
|
+
const gate = await acquireConcurrencyGate(user, "workflow_schedule_dispatch", {
|
|
11281
|
+
ttlSeconds: 60 * 60,
|
|
11282
|
+
metadata: { scheduleId: schedule.id, workflowId: schedule.workflow_id }
|
|
11026
11283
|
});
|
|
11027
|
-
|
|
11028
|
-
|
|
11029
|
-
results.push({ scheduleId: schedule.id, runId: run.id, status: run.status });
|
|
11284
|
+
if (!gate.ok) {
|
|
11285
|
+
results.push({ scheduleId: schedule.id, status: "deferred", error: concurrencyLimitExceededResponse(gate).message });
|
|
11030
11286
|
continue;
|
|
11031
11287
|
}
|
|
11288
|
+
let run = null;
|
|
11032
11289
|
try {
|
|
11290
|
+
run = await createWorkflowRun({
|
|
11291
|
+
userId: user.id,
|
|
11292
|
+
scheduleId: schedule.id,
|
|
11293
|
+
workflowId: schedule.workflow_id,
|
|
11294
|
+
workflowInput: schedule.input,
|
|
11295
|
+
idempotencyKey: `${schedule.id}:${scheduledFor}`
|
|
11296
|
+
});
|
|
11297
|
+
await markWorkflowScheduleRan(schedule.id, now, addCadence(scheduledFor, schedule.cadence));
|
|
11298
|
+
if (run.status !== "queued") {
|
|
11299
|
+
results.push({ scheduleId: schedule.id, runId: run.id, status: run.status });
|
|
11300
|
+
continue;
|
|
11301
|
+
}
|
|
11033
11302
|
const executed = await executeWorkflowRun({
|
|
11034
11303
|
runId: run.id,
|
|
11035
11304
|
user,
|
|
@@ -11037,11 +11306,14 @@ async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
|
|
|
11037
11306
|
workflowInput: schedule.input,
|
|
11038
11307
|
apiUrl,
|
|
11039
11308
|
scheduleId: schedule.id,
|
|
11040
|
-
webhookUrl: schedule.webhook_url
|
|
11309
|
+
webhookUrl: schedule.webhook_url,
|
|
11310
|
+
concurrencyLockId: gate.lockId
|
|
11041
11311
|
});
|
|
11042
11312
|
results.push({ scheduleId: schedule.id, runId: executed.run.id, status: executed.run.status });
|
|
11043
11313
|
} catch (err) {
|
|
11044
|
-
results.push({ scheduleId: schedule.id, runId: run
|
|
11314
|
+
results.push({ scheduleId: schedule.id, runId: run?.id, status: "failed", error: err instanceof Error ? err.message : String(err) });
|
|
11315
|
+
} finally {
|
|
11316
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
11045
11317
|
}
|
|
11046
11318
|
}
|
|
11047
11319
|
return { dispatched: results.length, results };
|
|
@@ -11803,26 +12075,6 @@ async function enforceSerpIntelligenceRateLimit(c, userId) {
|
|
|
11803
12075
|
{ "Retry-After": String(result.resetSeconds) }
|
|
11804
12076
|
);
|
|
11805
12077
|
}
|
|
11806
|
-
async function enforceSerpIntelligenceConcurrency(user) {
|
|
11807
|
-
const active = await countActiveJobsForUser(user.id);
|
|
11808
|
-
const limit = 1 + (user.extra_concurrency_slots ?? 0);
|
|
11809
|
-
if (active < limit) return null;
|
|
11810
|
-
return new Response(
|
|
11811
|
-
JSON.stringify(structuredError({
|
|
11812
|
-
error_code: "concurrency_limit_exceeded",
|
|
11813
|
-
error_type: "concurrency_limit",
|
|
11814
|
-
message: `You have ${active} active job${active !== 1 ? "s" : ""}. Your account allows ${limit} concurrent job${limit !== 1 ? "s" : ""}.`,
|
|
11815
|
-
retryable: true
|
|
11816
|
-
})),
|
|
11817
|
-
{
|
|
11818
|
-
status: 429,
|
|
11819
|
-
headers: {
|
|
11820
|
-
"Content-Type": "application/json",
|
|
11821
|
-
"Retry-After": "30"
|
|
11822
|
-
}
|
|
11823
|
-
}
|
|
11824
|
-
);
|
|
11825
|
-
}
|
|
11826
12078
|
function pageSnapshotTargetsFromBody(body) {
|
|
11827
12079
|
return body.targets?.length ? body.targets : body.urls.map((url) => ({
|
|
11828
12080
|
url,
|
|
@@ -11838,17 +12090,22 @@ serpIntelligenceApp.post("/capture", async (c) => {
|
|
|
11838
12090
|
if (!parsed.success) return c.json(formatZodError(parsed.error), 400);
|
|
11839
12091
|
const limited = await enforceSerpIntelligenceRateLimit(c, user.id);
|
|
11840
12092
|
if (limited) return limited;
|
|
11841
|
-
const
|
|
11842
|
-
|
|
12093
|
+
const gate = await acquireConcurrencyGate(user, "serp_intelligence_capture", {
|
|
12094
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
12095
|
+
metadata: { query: parsed.data.query, location: parsed.data.location }
|
|
12096
|
+
});
|
|
12097
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
11843
12098
|
const cost = MC_COSTS.serp;
|
|
11844
|
-
|
|
11845
|
-
user.id,
|
|
11846
|
-
cost,
|
|
11847
|
-
LedgerOperation.SERP,
|
|
11848
|
-
parsed.data.query
|
|
11849
|
-
);
|
|
11850
|
-
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, cost), 402);
|
|
12099
|
+
let debited = false;
|
|
11851
12100
|
try {
|
|
12101
|
+
const { ok, balance_mc } = await debitMc(
|
|
12102
|
+
user.id,
|
|
12103
|
+
cost,
|
|
12104
|
+
LedgerOperation.SERP,
|
|
12105
|
+
parsed.data.query
|
|
12106
|
+
);
|
|
12107
|
+
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, cost), 402);
|
|
12108
|
+
debited = true;
|
|
11852
12109
|
const result = await captureSerpIntelligenceSnapshot(parsed.data, {
|
|
11853
12110
|
kernelApiKey: browserServiceApiKey(),
|
|
11854
12111
|
kernelProxyId: browserServiceProxyId(),
|
|
@@ -11866,7 +12123,7 @@ serpIntelligenceApp.post("/capture", async (c) => {
|
|
|
11866
12123
|
});
|
|
11867
12124
|
return c.json(result);
|
|
11868
12125
|
} catch (error) {
|
|
11869
|
-
await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence capture");
|
|
12126
|
+
if (debited) await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence capture");
|
|
11870
12127
|
const body = error instanceof SerpIntelligenceCaptureError ? error.toJSON() : structuredError({
|
|
11871
12128
|
error_code: "capture_failed",
|
|
11872
12129
|
error_type: "capture_error",
|
|
@@ -11883,6 +12140,8 @@ serpIntelligenceApp.post("/capture", async (c) => {
|
|
|
11883
12140
|
});
|
|
11884
12141
|
const status = error instanceof SerpIntelligenceCaptureError ? error.httpStatus : 500;
|
|
11885
12142
|
return c.json(body, status);
|
|
12143
|
+
} finally {
|
|
12144
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
11886
12145
|
}
|
|
11887
12146
|
});
|
|
11888
12147
|
serpIntelligenceApp.post("/page-snapshots", async (c) => {
|
|
@@ -11893,18 +12152,23 @@ serpIntelligenceApp.post("/page-snapshots", async (c) => {
|
|
|
11893
12152
|
if (!parsed.success) return c.json(formatZodError(parsed.error), 400);
|
|
11894
12153
|
const limited = await enforceSerpIntelligenceRateLimit(c, user.id);
|
|
11895
12154
|
if (limited) return limited;
|
|
11896
|
-
const concurrencyLimited = await enforceSerpIntelligenceConcurrency(user);
|
|
11897
|
-
if (concurrencyLimited) return concurrencyLimited;
|
|
11898
12155
|
const targets = pageSnapshotTargetsFromBody(parsed.data);
|
|
12156
|
+
const gate = await acquireConcurrencyGate(user, "serp_intelligence_page_snapshots", {
|
|
12157
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
12158
|
+
metadata: { targets: targets.length }
|
|
12159
|
+
});
|
|
12160
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
11899
12161
|
const cost = targets.length * MC_COSTS.page_scrape;
|
|
11900
|
-
|
|
11901
|
-
user.id,
|
|
11902
|
-
cost,
|
|
11903
|
-
LedgerOperation.EXTRACT_URL,
|
|
11904
|
-
`serp intelligence page snapshots: ${targets.length}`
|
|
11905
|
-
);
|
|
11906
|
-
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, cost), 402);
|
|
12162
|
+
let debited = false;
|
|
11907
12163
|
try {
|
|
12164
|
+
const { ok, balance_mc } = await debitMc(
|
|
12165
|
+
user.id,
|
|
12166
|
+
cost,
|
|
12167
|
+
LedgerOperation.EXTRACT_URL,
|
|
12168
|
+
`serp intelligence page snapshots: ${targets.length}`
|
|
12169
|
+
);
|
|
12170
|
+
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, cost), 402);
|
|
12171
|
+
debited = true;
|
|
11908
12172
|
const result = await capturePageSnapshots(targets, {
|
|
11909
12173
|
kernelApiKey: browserServiceApiKey(),
|
|
11910
12174
|
timeoutMs: parsed.data.timeoutMs,
|
|
@@ -11921,7 +12185,7 @@ serpIntelligenceApp.post("/page-snapshots", async (c) => {
|
|
|
11921
12185
|
});
|
|
11922
12186
|
return c.json(result);
|
|
11923
12187
|
} catch (error) {
|
|
11924
|
-
await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence page snapshots");
|
|
12188
|
+
if (debited) await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence page snapshots");
|
|
11925
12189
|
const body = structuredError({
|
|
11926
12190
|
error_code: "page_snapshot_failed",
|
|
11927
12191
|
error_type: "capture_error",
|
|
@@ -11936,6 +12200,8 @@ serpIntelligenceApp.post("/page-snapshots", async (c) => {
|
|
|
11936
12200
|
error: body.message
|
|
11937
12201
|
});
|
|
11938
12202
|
return c.json(body, 500);
|
|
12203
|
+
} finally {
|
|
12204
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
11939
12205
|
}
|
|
11940
12206
|
});
|
|
11941
12207
|
|
|
@@ -12009,7 +12275,7 @@ mcpApp.all("/", async (c) => {
|
|
|
12009
12275
|
import { Hono as Hono10 } from "hono";
|
|
12010
12276
|
|
|
12011
12277
|
// src/api/browser-agent-db.ts
|
|
12012
|
-
import { randomUUID } from "crypto";
|
|
12278
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
12013
12279
|
var _ready = false;
|
|
12014
12280
|
async function migrateBrowserAgent() {
|
|
12015
12281
|
if (_ready) return;
|
|
@@ -12023,6 +12289,7 @@ async function migrateBrowserAgent() {
|
|
|
12023
12289
|
status TEXT NOT NULL DEFAULT 'open',
|
|
12024
12290
|
label TEXT,
|
|
12025
12291
|
user_id INTEGER,
|
|
12292
|
+
concurrency_lock_id TEXT,
|
|
12026
12293
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
12027
12294
|
closed_at TEXT,
|
|
12028
12295
|
last_action_at TEXT,
|
|
@@ -12032,6 +12299,11 @@ async function migrateBrowserAgent() {
|
|
|
12032
12299
|
`);
|
|
12033
12300
|
await db.execute(`CREATE INDEX IF NOT EXISTS browser_agent_sessions_status ON browser_agent_sessions(status)`);
|
|
12034
12301
|
await db.execute(`CREATE INDEX IF NOT EXISTS browser_agent_sessions_user ON browser_agent_sessions(user_id)`);
|
|
12302
|
+
try {
|
|
12303
|
+
await db.execute(`ALTER TABLE browser_agent_sessions ADD COLUMN concurrency_lock_id TEXT`);
|
|
12304
|
+
} catch {
|
|
12305
|
+
}
|
|
12306
|
+
await db.execute(`CREATE INDEX IF NOT EXISTS browser_agent_sessions_concurrency_lock ON browser_agent_sessions(concurrency_lock_id)`);
|
|
12035
12307
|
await db.execute(`
|
|
12036
12308
|
CREATE TABLE IF NOT EXISTS browser_agent_actions (
|
|
12037
12309
|
id TEXT PRIMARY KEY,
|
|
@@ -12059,11 +12331,11 @@ async function migrateBrowserAgent() {
|
|
|
12059
12331
|
}
|
|
12060
12332
|
async function createSessionRow(input) {
|
|
12061
12333
|
const db = getDb();
|
|
12062
|
-
const id = `bas_${
|
|
12334
|
+
const id = `bas_${randomUUID2().replace(/-/g, "").slice(0, 20)}`;
|
|
12063
12335
|
await db.execute({
|
|
12064
|
-
sql: `INSERT INTO browser_agent_sessions (id, runtime_session_id, live_view_url, cdp_ws_url, status, label, user_id, last_action_at)
|
|
12065
|
-
VALUES (?, ?, ?, ?, 'open', ?, ?, datetime('now'))`,
|
|
12066
|
-
args: [id, input.runtimeSessionId, input.liveViewUrl, input.cdpWsUrl, input.label, input.userId]
|
|
12336
|
+
sql: `INSERT INTO browser_agent_sessions (id, runtime_session_id, live_view_url, cdp_ws_url, status, label, user_id, concurrency_lock_id, last_action_at)
|
|
12337
|
+
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, datetime('now'))`,
|
|
12338
|
+
args: [id, input.runtimeSessionId, input.liveViewUrl, input.cdpWsUrl, input.label, input.userId, input.concurrencyLockId ?? null]
|
|
12067
12339
|
});
|
|
12068
12340
|
const row = await getSessionRow(id);
|
|
12069
12341
|
if (!row) throw new Error("session insert failed");
|
|
@@ -12120,7 +12392,7 @@ async function recordAction(input) {
|
|
|
12120
12392
|
sql: `INSERT INTO browser_agent_actions (id, session_id, type, params_json, ok, error)
|
|
12121
12393
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
12122
12394
|
args: [
|
|
12123
|
-
`baa_${
|
|
12395
|
+
`baa_${randomUUID2().replace(/-/g, "").slice(0, 20)}`,
|
|
12124
12396
|
input.sessionId,
|
|
12125
12397
|
input.type,
|
|
12126
12398
|
input.params == null ? null : JSON.stringify(input.params),
|
|
@@ -12469,6 +12741,13 @@ async function replayList(runtimeSessionId) {
|
|
|
12469
12741
|
|
|
12470
12742
|
// src/api/browser-agent-routes.ts
|
|
12471
12743
|
var auth = createApiKeyAuth();
|
|
12744
|
+
var DEFAULT_BROWSER_SESSION_LOCK_TTL_SECONDS = 2 * 60 * 60;
|
|
12745
|
+
function browserSessionLockTtlSeconds(timeoutSeconds) {
|
|
12746
|
+
if (typeof timeoutSeconds === "number" && Number.isFinite(timeoutSeconds) && timeoutSeconds > 0) {
|
|
12747
|
+
return Math.max(30 * 60, Math.min(Math.trunc(timeoutSeconds) + 5 * 60, 24 * 60 * 60));
|
|
12748
|
+
}
|
|
12749
|
+
return DEFAULT_BROWSER_SESSION_LOCK_TTL_SECONDS;
|
|
12750
|
+
}
|
|
12472
12751
|
async function charge(sessionId, userId, startedAtMs) {
|
|
12473
12752
|
const elapsedMs = Date.now() - startedAtMs;
|
|
12474
12753
|
const { active_ms, billed_mc } = await addActiveMs(sessionId, elapsedMs);
|
|
@@ -12524,6 +12803,8 @@ async function loadOpenSession(id, userId) {
|
|
|
12524
12803
|
const row = await getSessionRow(id);
|
|
12525
12804
|
if (!row) return null;
|
|
12526
12805
|
if (row.user_id != null && row.user_id !== userId) return null;
|
|
12806
|
+
if (row.status !== "open") return null;
|
|
12807
|
+
await extendConcurrencyGate(row.concurrency_lock_id, DEFAULT_BROWSER_SESSION_LOCK_TTL_SECONDS);
|
|
12527
12808
|
return row;
|
|
12528
12809
|
}
|
|
12529
12810
|
function buildBrowserAgentRoutes() {
|
|
@@ -12539,23 +12820,39 @@ function buildBrowserAgentRoutes() {
|
|
|
12539
12820
|
return c.json(insufficientBalanceResponse(Number(user.balance_mc ?? 0), BROWSER_OPEN_MIN_BALANCE_MC), 402);
|
|
12540
12821
|
}
|
|
12541
12822
|
const body = await c.req.json().catch(() => ({}));
|
|
12823
|
+
const timeoutSeconds = typeof body.timeout_seconds === "number" ? body.timeout_seconds : void 0;
|
|
12824
|
+
const gate = await acquireConcurrencyGate(user, "browser_agent_session", {
|
|
12825
|
+
ttlSeconds: browserSessionLockTtlSeconds(timeoutSeconds),
|
|
12826
|
+
metadata: { label: typeof body.label === "string" ? body.label : null }
|
|
12827
|
+
});
|
|
12828
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
12829
|
+
let runtimeSessionId = null;
|
|
12542
12830
|
try {
|
|
12543
12831
|
const created = await createSession({
|
|
12544
|
-
timeoutSeconds
|
|
12832
|
+
timeoutSeconds,
|
|
12545
12833
|
proxyId: typeof body.proxy_id === "string" ? body.proxy_id : void 0,
|
|
12546
12834
|
profileName: typeof body.profile === "string" ? body.profile : void 0,
|
|
12547
12835
|
disableDefaultProxy: body.disable_default_proxy === true,
|
|
12548
12836
|
viewport: body.viewport && typeof body.viewport === "object" ? body.viewport : void 0
|
|
12549
12837
|
});
|
|
12838
|
+
runtimeSessionId = created.runtimeSessionId;
|
|
12550
12839
|
const row = await createSessionRow({
|
|
12551
12840
|
runtimeSessionId: created.runtimeSessionId,
|
|
12552
12841
|
liveViewUrl: created.liveViewUrl,
|
|
12553
12842
|
cdpWsUrl: created.cdpWsUrl,
|
|
12554
12843
|
label: typeof body.label === "string" ? body.label : null,
|
|
12555
|
-
userId: user.id
|
|
12844
|
+
userId: user.id,
|
|
12845
|
+
concurrencyLockId: gate.lockId
|
|
12556
12846
|
});
|
|
12557
12847
|
return c.json({ ...publicSession(row), watch_url: `/console/${row.id}` });
|
|
12558
12848
|
} catch (err) {
|
|
12849
|
+
if (runtimeSessionId) {
|
|
12850
|
+
try {
|
|
12851
|
+
await closeSession(runtimeSessionId);
|
|
12852
|
+
} catch {
|
|
12853
|
+
}
|
|
12854
|
+
}
|
|
12855
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
12559
12856
|
return c.json(failure(err), 502);
|
|
12560
12857
|
}
|
|
12561
12858
|
});
|
|
@@ -12586,6 +12883,7 @@ function buildBrowserAgentRoutes() {
|
|
|
12586
12883
|
} catch {
|
|
12587
12884
|
}
|
|
12588
12885
|
await markSessionClosed(row.id);
|
|
12886
|
+
await releaseConcurrencyGate(row.concurrency_lock_id);
|
|
12589
12887
|
return c.json({ ok: true });
|
|
12590
12888
|
});
|
|
12591
12889
|
app2.post("/sessions/:id/goto", async (c) => {
|
|
@@ -13542,9 +13840,6 @@ app.delete("/api-key", requireAllowedOrigin, sessionAuth, async (c) => {
|
|
|
13542
13840
|
await revokeApiKey(user.id);
|
|
13543
13841
|
return c.json({ ok: true });
|
|
13544
13842
|
});
|
|
13545
|
-
var BYPASS_EMAILS = new Set(
|
|
13546
|
-
(process.env.HARVEST_LIMIT_BYPASS_EMAILS ?? "").split(",").map((e) => e.trim()).filter(Boolean)
|
|
13547
|
-
);
|
|
13548
13843
|
var SYNC_HARVEST_TIMEOUT_OVERRIDE_MS = (() => {
|
|
13549
13844
|
const raw = process.env.SYNC_HARVEST_TIMEOUT_MS;
|
|
13550
13845
|
const parsed = raw === void 0 ? NaN : Number(raw);
|
|
@@ -13573,11 +13868,20 @@ function countPaaQuestions(result) {
|
|
|
13573
13868
|
function paaCostForQuestionCount(questionCount) {
|
|
13574
13869
|
return Math.max(1, questionCount) * MC_COSTS.paa;
|
|
13575
13870
|
}
|
|
13576
|
-
async function checkHarvestLimits(
|
|
13577
|
-
if (
|
|
13578
|
-
|
|
13579
|
-
const
|
|
13580
|
-
|
|
13871
|
+
async function checkHarvestLimits(user, reuseLockId) {
|
|
13872
|
+
if (reuseLockId && await reuseExistingConcurrencyGate(user.id, reuseLockId, 30 * 60)) return null;
|
|
13873
|
+
if (isConcurrencyLimitBypassed(user)) return null;
|
|
13874
|
+
const limit = concurrencyLimitForUser(user);
|
|
13875
|
+
const active = await countActiveUsageForUser(user.id);
|
|
13876
|
+
if (active >= limit) {
|
|
13877
|
+
return concurrencyLimitExceededResponse({
|
|
13878
|
+
ok: false,
|
|
13879
|
+
active,
|
|
13880
|
+
limit,
|
|
13881
|
+
operation: "harvest",
|
|
13882
|
+
retryAfterSeconds: 30
|
|
13883
|
+
});
|
|
13884
|
+
}
|
|
13581
13885
|
return null;
|
|
13582
13886
|
}
|
|
13583
13887
|
app.post("/harvest", auth2, async (c) => {
|
|
@@ -13592,8 +13896,8 @@ app.post("/harvest", auth2, async (c) => {
|
|
|
13592
13896
|
if (checked.error) return c.json({ error: checked.error }, 400);
|
|
13593
13897
|
body.callback_url = checked.parsed?.href;
|
|
13594
13898
|
}
|
|
13595
|
-
const limitErr = await checkHarvestLimits(user
|
|
13596
|
-
if (limitErr) return c.json(limitErr, 429);
|
|
13899
|
+
const limitErr = await checkHarvestLimits(user, c.req.header("x-mcp-scraper-concurrency-lock"));
|
|
13900
|
+
if (limitErr) return c.json(limitErr, 429, { "Retry-After": "30" });
|
|
13597
13901
|
const options = {
|
|
13598
13902
|
query: body.query.trim(),
|
|
13599
13903
|
location: body.location,
|
|
@@ -13628,8 +13932,8 @@ app.post("/harvest/sync", auth2, async (c) => {
|
|
|
13628
13932
|
const bodyResult = HarvestBodySchema.safeParse(raw);
|
|
13629
13933
|
if (!bodyResult.success) return c.json({ error: bodyResult.error.issues[0]?.message ?? "Invalid request" }, 400);
|
|
13630
13934
|
const body = bodyResult.data;
|
|
13631
|
-
const limitErr = await checkHarvestLimits(user
|
|
13632
|
-
if (limitErr) return c.json(limitErr, 429);
|
|
13935
|
+
const limitErr = await checkHarvestLimits(user, c.req.header("x-mcp-scraper-concurrency-lock"));
|
|
13936
|
+
if (limitErr) return c.json(limitErr, 429, { "Retry-After": "30" });
|
|
13633
13937
|
const options = {
|
|
13634
13938
|
query: body.query.trim(),
|
|
13635
13939
|
location: body.location,
|
|
@@ -13788,9 +14092,16 @@ app.post("/extract-url", auth2, async (c) => {
|
|
|
13788
14092
|
}
|
|
13789
14093
|
})();
|
|
13790
14094
|
const user = c.get("user");
|
|
13791
|
-
const
|
|
13792
|
-
|
|
14095
|
+
const gate = await acquireConcurrencyGate(user, "extract_url", {
|
|
14096
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
14097
|
+
metadata: { url: canonicalUrl }
|
|
14098
|
+
});
|
|
14099
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
14100
|
+
let debited = false;
|
|
13793
14101
|
try {
|
|
14102
|
+
const { ok: euOk, balance_mc: euBal } = await debitMc(user.id, MC_COSTS.page_scrape, LedgerOperation.EXTRACT_URL, new URL(canonicalUrl).hostname);
|
|
14103
|
+
if (!euOk) return c.json(insufficientBalanceResponse(euBal, MC_COSTS.page_scrape), 402);
|
|
14104
|
+
debited = true;
|
|
13794
14105
|
const kernelApiKey = browserServiceApiKey();
|
|
13795
14106
|
const device = screenshotDevice === "mobile" ? "mobile" : "desktop";
|
|
13796
14107
|
const [result, pageData] = await Promise.all([
|
|
@@ -13808,9 +14119,11 @@ app.post("/extract-url", auth2, async (c) => {
|
|
|
13808
14119
|
return c.json({ ...result, screenshot: screenshotMeta, branding: brandingData, media: mediaMeta });
|
|
13809
14120
|
} catch (err) {
|
|
13810
14121
|
const msg = err instanceof Error ? err.message : String(err);
|
|
13811
|
-
await creditMc(user.id, MC_COSTS.page_scrape, LedgerOperation.EXTRACT_URL, "failed call");
|
|
14122
|
+
if (debited) await creditMc(user.id, MC_COSTS.page_scrape, LedgerOperation.EXTRACT_URL, "failed call");
|
|
13812
14123
|
await logRequestEvent({ userId: user.id, source: "extract_url", status: "failed", query: canonicalUrl, error: msg });
|
|
13813
14124
|
return c.json({ error: msg }, 500);
|
|
14125
|
+
} finally {
|
|
14126
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
13814
14127
|
}
|
|
13815
14128
|
});
|
|
13816
14129
|
app.post("/map-urls", auth2, async (c) => {
|
|
@@ -13822,9 +14135,16 @@ app.post("/map-urls", auth2, async (c) => {
|
|
|
13822
14135
|
if (checked.error || !checked.parsed) return c.json({ error: checked.error ?? "Invalid URL" }, 400);
|
|
13823
14136
|
const parsed = checked.parsed;
|
|
13824
14137
|
const user = c.get("user");
|
|
13825
|
-
const
|
|
13826
|
-
|
|
14138
|
+
const gate = await acquireConcurrencyGate(user, "map_urls", {
|
|
14139
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
14140
|
+
metadata: { url: parsed.href }
|
|
14141
|
+
});
|
|
14142
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
14143
|
+
let debited = false;
|
|
13827
14144
|
try {
|
|
14145
|
+
const { ok: mapOk, balance_mc: mapBal } = await debitMc(user.id, MC_COSTS.url_map, LedgerOperation.URL_MAP, parsed.hostname);
|
|
14146
|
+
if (!mapOk) return c.json(insufficientBalanceResponse(mapBal, MC_COSTS.url_map), 402);
|
|
14147
|
+
debited = true;
|
|
13828
14148
|
const result = await spiderSite({
|
|
13829
14149
|
startUrl: parsed.href,
|
|
13830
14150
|
maxUrls: Math.min(2e3, Math.max(1, body.maxUrls ?? 500)),
|
|
@@ -13842,7 +14162,7 @@ app.post("/map-urls", auth2, async (c) => {
|
|
|
13842
14162
|
return c.json(result);
|
|
13843
14163
|
} catch (err) {
|
|
13844
14164
|
const msg = err instanceof Error ? err.message : String(err);
|
|
13845
|
-
await creditMc(user.id, MC_COSTS.url_map, LedgerOperation.URL_MAP_REFUND, "failed call");
|
|
14165
|
+
if (debited) await creditMc(user.id, MC_COSTS.url_map, LedgerOperation.URL_MAP_REFUND, "failed call");
|
|
13846
14166
|
await logRequestEvent({
|
|
13847
14167
|
userId: user.id,
|
|
13848
14168
|
source: "map_urls",
|
|
@@ -13851,6 +14171,8 @@ app.post("/map-urls", auth2, async (c) => {
|
|
|
13851
14171
|
error: msg
|
|
13852
14172
|
});
|
|
13853
14173
|
return c.json({ error: msg }, 500);
|
|
14174
|
+
} finally {
|
|
14175
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
13854
14176
|
}
|
|
13855
14177
|
});
|
|
13856
14178
|
app.post("/extract-site", auth2, async (c) => {
|
|
@@ -13863,9 +14185,16 @@ app.post("/extract-site", auth2, async (c) => {
|
|
|
13863
14185
|
const parsed = checked.parsed;
|
|
13864
14186
|
const user = c.get("user");
|
|
13865
14187
|
const siteHoldMc = MC_COSTS.page_scrape * 10;
|
|
13866
|
-
const
|
|
13867
|
-
|
|
14188
|
+
const gate = await acquireConcurrencyGate(user, "extract_site", {
|
|
14189
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
14190
|
+
metadata: { url: parsed.href }
|
|
14191
|
+
});
|
|
14192
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
14193
|
+
let debited = false;
|
|
13868
14194
|
try {
|
|
14195
|
+
const { ok: siteOk, balance_mc: siteBal } = await debitMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_HOLD, parsed.hostname);
|
|
14196
|
+
if (!siteOk) return c.json(insufficientBalanceResponse(siteBal, siteHoldMc), 402);
|
|
14197
|
+
debited = true;
|
|
13869
14198
|
const result = await extractSite({
|
|
13870
14199
|
startUrl: parsed.href,
|
|
13871
14200
|
maxPages: Math.min(200, Math.max(1, body.maxPages ?? 100)),
|
|
@@ -13886,7 +14215,7 @@ app.post("/extract-site", auth2, async (c) => {
|
|
|
13886
14215
|
});
|
|
13887
14216
|
return c.json(result);
|
|
13888
14217
|
} catch (err) {
|
|
13889
|
-
await creditMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_REFUND, "failed call");
|
|
14218
|
+
if (debited) await creditMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_REFUND, "failed call");
|
|
13890
14219
|
const msg = err instanceof Error ? err.message : String(err);
|
|
13891
14220
|
await logRequestEvent({
|
|
13892
14221
|
userId: user.id,
|
|
@@ -13896,6 +14225,8 @@ app.post("/extract-site", auth2, async (c) => {
|
|
|
13896
14225
|
error: msg
|
|
13897
14226
|
});
|
|
13898
14227
|
return c.json({ error: msg }, 500);
|
|
14228
|
+
} finally {
|
|
14229
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
13899
14230
|
}
|
|
13900
14231
|
});
|
|
13901
14232
|
app.post("/billing/checkout", requireAllowedOrigin, sessionAuth, async (c) => {
|
|
@@ -13964,6 +14295,60 @@ app.post("/billing/concurrency/checkout", requireAllowedOrigin, sessionAuth, asy
|
|
|
13964
14295
|
return c.json({ error: message }, 500);
|
|
13965
14296
|
}
|
|
13966
14297
|
});
|
|
14298
|
+
app.post("/billing/concurrency/terminal-checkout", auth2, async (c) => {
|
|
14299
|
+
try {
|
|
14300
|
+
const user = c.get("user");
|
|
14301
|
+
const upgrade = concurrencySlotBillingInfo();
|
|
14302
|
+
if (user.concurrency_stripe_sub_id) {
|
|
14303
|
+
return c.json({
|
|
14304
|
+
error: "Already subscribed. Your account already has an active extra concurrency slot subscription.",
|
|
14305
|
+
error_code: "concurrency_subscription_exists",
|
|
14306
|
+
concurrency: {
|
|
14307
|
+
extra_slots: user.extra_concurrency_slots,
|
|
14308
|
+
limit: concurrencyLimitForUser(user),
|
|
14309
|
+
has_subscription: true,
|
|
14310
|
+
upgrade
|
|
14311
|
+
}
|
|
14312
|
+
}, 409);
|
|
14313
|
+
}
|
|
14314
|
+
const secret2 = process.env.STRIPE_SECRET_KEY?.trim();
|
|
14315
|
+
if (!secret2) return c.json({ error: "Stripe is not configured." }, 503);
|
|
14316
|
+
const stripeClient = new Stripe2(secret2, { apiVersion: STRIPE_API_VERSION });
|
|
14317
|
+
let customerId = user.stripe_customer_id;
|
|
14318
|
+
if (!customerId) {
|
|
14319
|
+
const customer = await stripeClient.customers.create({ email: user.email });
|
|
14320
|
+
customerId = customer.id;
|
|
14321
|
+
await setStripeCustomerId(user.id, customerId);
|
|
14322
|
+
}
|
|
14323
|
+
const session = await stripeClient.checkout.sessions.create({
|
|
14324
|
+
customer: customerId,
|
|
14325
|
+
mode: "subscription",
|
|
14326
|
+
line_items: [{ price: CONCURRENCY_PRICE_ID, quantity: 1 }],
|
|
14327
|
+
automatic_tax: { enabled: true },
|
|
14328
|
+
billing_address_collection: "required",
|
|
14329
|
+
customer_update: { address: "auto", name: "auto" },
|
|
14330
|
+
tax_id_collection: { enabled: true },
|
|
14331
|
+
success_url: `${appOrigin()}/billing?slot_added=1&source=terminal`,
|
|
14332
|
+
cancel_url: `${appOrigin()}/billing?slot_cancelled=1&source=terminal`
|
|
14333
|
+
});
|
|
14334
|
+
if (!session.url) return c.json({ error: "Stripe did not return a checkout URL." }, 502);
|
|
14335
|
+
return c.json({
|
|
14336
|
+
checkout_url: session.url,
|
|
14337
|
+
price: upgrade,
|
|
14338
|
+
concurrency: {
|
|
14339
|
+
current_extra_slots: user.extra_concurrency_slots,
|
|
14340
|
+
current_limit: concurrencyLimitForUser(user),
|
|
14341
|
+
after_checkout_extra_slots: user.extra_concurrency_slots + 1,
|
|
14342
|
+
after_checkout_limit: concurrencyLimitForUser({ extra_concurrency_slots: user.extra_concurrency_slots + 1 })
|
|
14343
|
+
},
|
|
14344
|
+
next_step: "Open checkout_url in a browser, complete checkout, then restart or retry the MCP request."
|
|
14345
|
+
});
|
|
14346
|
+
} catch (err) {
|
|
14347
|
+
const message = err instanceof Error ? err.message : "Unable to start terminal checkout.";
|
|
14348
|
+
console.error("[billing/concurrency/terminal-checkout]", message);
|
|
14349
|
+
return c.json({ error: message }, 500);
|
|
14350
|
+
}
|
|
14351
|
+
});
|
|
13967
14352
|
app.post("/billing/concurrency/cancel", requireAllowedOrigin, sessionAuth, async (c) => {
|
|
13968
14353
|
const user = c.get("sessionUser");
|
|
13969
14354
|
if (!user.concurrency_stripe_sub_id) return c.json({ error: "No active concurrency subscription." }, 404);
|
|
@@ -13971,9 +14356,10 @@ app.post("/billing/concurrency/cancel", requireAllowedOrigin, sessionAuth, async
|
|
|
13971
14356
|
if (!stripeSecret) return c.json({ error: "Stripe is not configured." }, 503);
|
|
13972
14357
|
const stripeClient = new Stripe2(stripeSecret, { apiVersion: STRIPE_API_VERSION });
|
|
13973
14358
|
await stripeClient.subscriptions.cancel(user.concurrency_stripe_sub_id);
|
|
13974
|
-
|
|
14359
|
+
const nextSlots = Math.max(0, user.extra_concurrency_slots - 1);
|
|
14360
|
+
await setExtraConcurrencySlots(user.id, nextSlots);
|
|
13975
14361
|
await setConcurrencySubId(user.id, null);
|
|
13976
|
-
return c.json({ ok: true, concurrency_limit:
|
|
14362
|
+
return c.json({ ok: true, extra_concurrency_slots: nextSlots, concurrency_limit: 1 + nextSlots });
|
|
13977
14363
|
});
|
|
13978
14364
|
app.get("/billing/balance", auth2, async (c) => {
|
|
13979
14365
|
const user = c.get("user");
|
|
@@ -14008,6 +14394,12 @@ app.post("/billing/credits", auth2, async (c) => {
|
|
|
14008
14394
|
item: body.item ?? null,
|
|
14009
14395
|
matched_cost: matchedCost ? (({ aliases, ...cost }) => cost)(matchedCost) : null,
|
|
14010
14396
|
costs,
|
|
14397
|
+
concurrency: {
|
|
14398
|
+
current_extra_slots: user.extra_concurrency_slots,
|
|
14399
|
+
current_limit: concurrencyLimitForUser(user),
|
|
14400
|
+
has_subscription: !!user.concurrency_stripe_sub_id,
|
|
14401
|
+
upgrade: concurrencySlotBillingInfo()
|
|
14402
|
+
},
|
|
14011
14403
|
ledger
|
|
14012
14404
|
});
|
|
14013
14405
|
});
|
|
@@ -14016,7 +14408,7 @@ app.get("/cron/tick", async (c) => {
|
|
|
14016
14408
|
if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
|
|
14017
14409
|
return c.json({ error: "Unauthorized" }, 401);
|
|
14018
14410
|
}
|
|
14019
|
-
const { drainQueue } = await import("./worker-
|
|
14411
|
+
const { drainQueue } = await import("./worker-OZSWIS3F.js");
|
|
14020
14412
|
const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
|
|
14021
14413
|
const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
|
|
14022
14414
|
const [results, sweepResult] = await Promise.all([
|
|
@@ -14144,4 +14536,4 @@ app.get("/blog/:slug/", (c) => {
|
|
|
14144
14536
|
export {
|
|
14145
14537
|
app
|
|
14146
14538
|
};
|
|
14147
|
-
//# sourceMappingURL=server-
|
|
14539
|
+
//# sourceMappingURL=server-7QP6HQJJ.js.map
|