mcp-scraper 0.2.13 → 0.2.15
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 +12 -2
- package/dist/bin/api-server.cjs +977 -200
- 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 +135 -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 +4 -3
- package/dist/bin/mcp-scraper-install.cjs.map +1 -1
- package/dist/bin/mcp-scraper-install.js +4 -3
- package/dist/bin/mcp-scraper-install.js.map +1 -1
- package/dist/bin/mcp-stdio-server.cjs +135 -5
- package/dist/bin/mcp-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-stdio-server.js +2 -2
- package/dist/chunk-2CQXHSWC.js +7 -0
- package/dist/chunk-2CQXHSWC.js.map +1 -0
- 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-Q4DFONIK.js} +136 -6
- package/dist/chunk-Q4DFONIK.js.map +1 -0
- package/dist/{chunk-BQTWXY6G.js → chunk-TIPUIEJN.js} +2 -2
- package/dist/{chunk-7GCCOT3M.js → chunk-TL7YTFLH.js} +15 -1
- package/dist/chunk-TL7YTFLH.js.map +1 -0
- package/dist/{db-BVHYI57K.js → db-P5X6UQ3E.js} +2 -2
- package/dist/{server-LUNOI26E.js → server-VD2TD3AD.js} +773 -184
- package/dist/server-VD2TD3AD.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-TIPUIEJN.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-Q4DFONIK.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-2CQXHSWC.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
|
|
|
@@ -9003,6 +9189,127 @@ function advertisersFromResults(results, maxResults) {
|
|
|
9003
9189
|
return [...byPage.values()].map((e) => ({ pageName: e.pageName, pageId: e.pageId, sampleLibraryId: e.sampleLibraryId, adCount: Math.max(e.maxCollation, e.resultCount) })).sort((a, b) => b.adCount - a.adCount).slice(0, maxResults);
|
|
9004
9190
|
}
|
|
9005
9191
|
|
|
9192
|
+
// src/extractor/FacebookOrganicVideoExtractor.ts
|
|
9193
|
+
function htmlDecode(value) {
|
|
9194
|
+
return value.replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">");
|
|
9195
|
+
}
|
|
9196
|
+
function decodeEscapedValue(raw) {
|
|
9197
|
+
let out = raw;
|
|
9198
|
+
try {
|
|
9199
|
+
out = JSON.parse(`"${raw.replace(/"/g, '\\"')}"`);
|
|
9200
|
+
} catch {
|
|
9201
|
+
out = raw.replace(/\\u0025/g, "%").replace(/\\u0026/g, "&").replace(/\\u003D/g, "=").replace(/\\u003d/g, "=").replace(/\\"/g, '"').replace(/\\\//g, "/");
|
|
9202
|
+
}
|
|
9203
|
+
return htmlDecode(out);
|
|
9204
|
+
}
|
|
9205
|
+
function facebookVideoIdFromUrl(url) {
|
|
9206
|
+
try {
|
|
9207
|
+
const parsed = new URL(url);
|
|
9208
|
+
const path5 = parsed.pathname;
|
|
9209
|
+
const direct = path5.match(/\/(?:reel|videos|watch)\/(?:[^/]+\/)?(\d{8,})/i);
|
|
9210
|
+
if (direct) return direct[1];
|
|
9211
|
+
const queryVideoId = parsed.searchParams.get("v") ?? parsed.searchParams.get("video_id");
|
|
9212
|
+
if (queryVideoId && /^\d{8,}$/.test(queryVideoId)) return queryVideoId;
|
|
9213
|
+
const numericTail = path5.match(/\/(\d{8,})\/?$/);
|
|
9214
|
+
if (numericTail) return numericTail[1];
|
|
9215
|
+
} catch {
|
|
9216
|
+
}
|
|
9217
|
+
return null;
|
|
9218
|
+
}
|
|
9219
|
+
function parseEfg(url) {
|
|
9220
|
+
try {
|
|
9221
|
+
const efg = new URL(url).searchParams.get("efg");
|
|
9222
|
+
if (!efg) return null;
|
|
9223
|
+
return JSON.parse(Buffer.from(efg, "base64").toString("utf8"));
|
|
9224
|
+
} catch {
|
|
9225
|
+
return null;
|
|
9226
|
+
}
|
|
9227
|
+
}
|
|
9228
|
+
function manifestDurationSec(htmlWindow) {
|
|
9229
|
+
const decoded = decodeEscapedValue(htmlWindow);
|
|
9230
|
+
const match = decoded.match(/mediaPresentationDuration="PT([\d.]+)S"/);
|
|
9231
|
+
return match ? Number(match[1]) : null;
|
|
9232
|
+
}
|
|
9233
|
+
function metadataContent(html, key) {
|
|
9234
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9235
|
+
const re = new RegExp(`<meta\\s+[^>]*(?:property|name)=["']${escaped}["'][^>]*content=["']([^"']+)["'][^>]*>`, "i");
|
|
9236
|
+
const m = html.match(re);
|
|
9237
|
+
return m ? htmlDecode(m[1]).trim() : null;
|
|
9238
|
+
}
|
|
9239
|
+
function ownerNameFromHtml(html) {
|
|
9240
|
+
const ogTitle = metadataContent(html, "og:title");
|
|
9241
|
+
const pipe = ogTitle?.split("|").map((s) => s.trim()).filter(Boolean);
|
|
9242
|
+
if (pipe && pipe.length > 1) return pipe[pipe.length - 1] ?? null;
|
|
9243
|
+
return null;
|
|
9244
|
+
}
|
|
9245
|
+
function extractFacebookOrganicVideo(html, pageUrl, sourceUrl = pageUrl, quality = "best") {
|
|
9246
|
+
const videoId = facebookVideoIdFromUrl(pageUrl) ?? facebookVideoIdFromUrl(sourceUrl);
|
|
9247
|
+
const manifestIndex = videoId ? html.indexOf(`dash_mpd_debug.mpd?v=${videoId}`) : -1;
|
|
9248
|
+
const searchWindow = manifestIndex >= 0 ? html.slice(Math.max(0, manifestIndex - 5e4), Math.min(html.length, manifestIndex + 5e4)) : html;
|
|
9249
|
+
const targetDuration = manifestDurationSec(searchWindow);
|
|
9250
|
+
const candidates = [];
|
|
9251
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9252
|
+
const re = /browser_native_(sd|hd)_url\\?"\s*:\s*\\?"([^"<]+?)\\?"/g;
|
|
9253
|
+
let match;
|
|
9254
|
+
while ((match = re.exec(searchWindow)) !== null) {
|
|
9255
|
+
const url = decodeEscapedValue(match[2]);
|
|
9256
|
+
if (!url || seen.has(url)) continue;
|
|
9257
|
+
seen.add(url);
|
|
9258
|
+
const parsed = parseEfg(url);
|
|
9259
|
+
let bitrate = null;
|
|
9260
|
+
try {
|
|
9261
|
+
const value = new URL(url).searchParams.get("bitrate");
|
|
9262
|
+
bitrate = value ? Number(value) : null;
|
|
9263
|
+
} catch {
|
|
9264
|
+
}
|
|
9265
|
+
candidates.push({
|
|
9266
|
+
url,
|
|
9267
|
+
quality: match[1],
|
|
9268
|
+
bitrate: Number.isFinite(bitrate) ? bitrate : null,
|
|
9269
|
+
durationSec: typeof parsed?.duration_s === "number" ? parsed.duration_s : null,
|
|
9270
|
+
assetId: typeof parsed?.xpv_asset_id === "number" ? parsed.xpv_asset_id : null,
|
|
9271
|
+
vencodeTag: typeof parsed?.vencode_tag === "string" ? parsed.vencode_tag : null
|
|
9272
|
+
});
|
|
9273
|
+
}
|
|
9274
|
+
const targetCandidates = candidates.filter((candidate) => {
|
|
9275
|
+
if (!targetDuration || candidate.durationSec == null) return true;
|
|
9276
|
+
return Math.abs(candidate.durationSec - targetDuration) <= 2;
|
|
9277
|
+
});
|
|
9278
|
+
const selectable = targetCandidates.length ? targetCandidates : candidates;
|
|
9279
|
+
const selected = [...selectable].filter((candidate) => quality === "best" || candidate.quality === quality).sort((a, b) => {
|
|
9280
|
+
const aq = a.quality === "hd" ? 1 : 0;
|
|
9281
|
+
const bq = b.quality === "hd" ? 1 : 0;
|
|
9282
|
+
if (quality === "best" && aq !== bq) return bq - aq;
|
|
9283
|
+
return (b.bitrate ?? 0) - (a.bitrate ?? 0);
|
|
9284
|
+
})[0];
|
|
9285
|
+
if (!selected) {
|
|
9286
|
+
throw new Error("No progressive Facebook video URL was found in the rendered page state");
|
|
9287
|
+
}
|
|
9288
|
+
return {
|
|
9289
|
+
sourceUrl,
|
|
9290
|
+
pageUrl,
|
|
9291
|
+
videoId,
|
|
9292
|
+
title: metadataContent(html, "og:title") ?? metadataContent(html, "twitter:title"),
|
|
9293
|
+
description: metadataContent(html, "og:description") ?? metadataContent(html, "description") ?? metadataContent(html, "twitter:description"),
|
|
9294
|
+
ownerName: ownerNameFromHtml(html),
|
|
9295
|
+
videoUrl: selected.url,
|
|
9296
|
+
selectedQuality: selected.quality,
|
|
9297
|
+
bitrate: selected.bitrate,
|
|
9298
|
+
durationSec: targetDuration ?? selected.durationSec,
|
|
9299
|
+
assetId: selected.assetId,
|
|
9300
|
+
vencodeTag: selected.vencodeTag,
|
|
9301
|
+
candidates: selectable,
|
|
9302
|
+
extractedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
9303
|
+
};
|
|
9304
|
+
}
|
|
9305
|
+
async function extractFacebookOrganicVideoFromPage(page, sourceUrl, quality = "best") {
|
|
9306
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 15e3 }).catch(() => {
|
|
9307
|
+
});
|
|
9308
|
+
await page.waitForTimeout(1500);
|
|
9309
|
+
const html = await page.content();
|
|
9310
|
+
return extractFacebookOrganicVideo(html, page.url(), sourceUrl, quality);
|
|
9311
|
+
}
|
|
9312
|
+
|
|
9006
9313
|
// src/api/facebook-ad-routes.ts
|
|
9007
9314
|
import { fal as fal2 } from "@fal-ai/client";
|
|
9008
9315
|
var FacebookAdBodySchema = z13.object({
|
|
@@ -9022,6 +9329,10 @@ var FacebookPageIntelBodySchema = z13.object({
|
|
|
9022
9329
|
var FacebookTranscribeBodySchema = z13.object({
|
|
9023
9330
|
videoUrl: z13.string().trim().min(1, "videoUrl is required")
|
|
9024
9331
|
});
|
|
9332
|
+
var FacebookVideoTranscribeBodySchema = z13.object({
|
|
9333
|
+
url: z13.string().trim().min(1, "url is required"),
|
|
9334
|
+
quality: z13.enum(["best", "hd", "sd"]).default("best")
|
|
9335
|
+
});
|
|
9025
9336
|
var FacebookSearchBodySchema = z13.object({
|
|
9026
9337
|
query: z13.string().trim().min(1, "query is required"),
|
|
9027
9338
|
country: z13.string().trim().toUpperCase().optional(),
|
|
@@ -9034,6 +9345,39 @@ var FacebookMediaBodySchema = z13.object({
|
|
|
9034
9345
|
function invalidRequest(message) {
|
|
9035
9346
|
return { error_code: "invalid_request", message };
|
|
9036
9347
|
}
|
|
9348
|
+
function isAllowedFacebookOrganicUrl(url) {
|
|
9349
|
+
const hostname = url.hostname.toLowerCase().replace(/^www\./, "").replace(/^m\./, "");
|
|
9350
|
+
return hostname === "facebook.com" || hostname === "fb.watch";
|
|
9351
|
+
}
|
|
9352
|
+
function facebookTranscriptMarkdown(title, text, chunks, durationMs) {
|
|
9353
|
+
const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
|
9354
|
+
const lines = [title, "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
|
|
9355
|
+
if (chunks.length) {
|
|
9356
|
+
lines.push("## Timestamped Segments", "");
|
|
9357
|
+
for (const ch of chunks) {
|
|
9358
|
+
lines.push(`**[${fmtTs2(ch.timestamp[0])} -> ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
|
|
9359
|
+
}
|
|
9360
|
+
}
|
|
9361
|
+
return lines.join("\n");
|
|
9362
|
+
}
|
|
9363
|
+
async function transcribeFacebookVideoUrl(videoUrl, markdownTitle = "# Facebook Video Transcript") {
|
|
9364
|
+
const startMs = Date.now();
|
|
9365
|
+
const result = await fal2.subscribe("fal-ai/wizper", {
|
|
9366
|
+
input: { audio_url: videoUrl, task: "transcribe", language: "en" },
|
|
9367
|
+
logs: false,
|
|
9368
|
+
pollInterval: 3e3
|
|
9369
|
+
});
|
|
9370
|
+
const data = result.data;
|
|
9371
|
+
const text = data.text ?? "";
|
|
9372
|
+
const chunks = data.chunks ?? [];
|
|
9373
|
+
const durationMs = Date.now() - startMs;
|
|
9374
|
+
return {
|
|
9375
|
+
text,
|
|
9376
|
+
chunks,
|
|
9377
|
+
durationMs,
|
|
9378
|
+
markdown: facebookTranscriptMarkdown(markdownTitle, text, chunks, durationMs)
|
|
9379
|
+
};
|
|
9380
|
+
}
|
|
9037
9381
|
async function detectSoftBlock(driver) {
|
|
9038
9382
|
const page = driver.getPage();
|
|
9039
9383
|
const bodyText = await page.evaluate(() => document.body?.innerText ?? "").catch(() => "");
|
|
@@ -9075,10 +9419,17 @@ facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
|
|
|
9075
9419
|
const libraryId = FacebookAdExtractor.resolveLibraryId(raw2);
|
|
9076
9420
|
if (!libraryId) return c.json({ error: "Could not resolve a valid Facebook Ad Library ID from the provided input" }, 400);
|
|
9077
9421
|
const fbUser = c.get("user");
|
|
9078
|
-
const
|
|
9079
|
-
|
|
9422
|
+
const gate = await acquireConcurrencyGate(fbUser, "facebook_ad", {
|
|
9423
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
9424
|
+
metadata: { libraryId }
|
|
9425
|
+
});
|
|
9426
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
9080
9427
|
const driver = new BrowserDriver();
|
|
9428
|
+
let debited = false;
|
|
9081
9429
|
try {
|
|
9430
|
+
const { ok: adOk, balance_mc: adBal } = await debitMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD, raw2);
|
|
9431
|
+
if (!adOk) return c.json(insufficientBalanceResponse(adBal, MC_COSTS.fb_ad), 402);
|
|
9432
|
+
debited = true;
|
|
9082
9433
|
await driver.launch(kernelLaunchOpts());
|
|
9083
9434
|
const extractor = new FacebookAdExtractor(driver);
|
|
9084
9435
|
const result = await extractor.extract(libraryId, { openModal: body.openModal !== false });
|
|
@@ -9092,7 +9443,7 @@ facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
|
|
|
9092
9443
|
});
|
|
9093
9444
|
return c.json(result);
|
|
9094
9445
|
} catch (err) {
|
|
9095
|
-
await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
|
|
9446
|
+
if (debited) await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
|
|
9096
9447
|
const msg = err instanceof Error ? err.message : String(err);
|
|
9097
9448
|
await logRequestEvent({ userId: fbUser.id, source: "facebook_ad", status: "failed", query: raw2, error: msg });
|
|
9098
9449
|
if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
|
|
@@ -9101,24 +9452,26 @@ facebookAdApp.post("/ad", createApiKeyAuth(), async (c) => {
|
|
|
9101
9452
|
return c.json({ error: msg }, 500);
|
|
9102
9453
|
} finally {
|
|
9103
9454
|
await driver.close();
|
|
9455
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
9104
9456
|
}
|
|
9105
9457
|
});
|
|
9106
9458
|
facebookAdApp.post("/page-intel", createApiKeyAuth(), async (c) => {
|
|
9107
9459
|
const raw = await c.req.json().catch(() => ({}));
|
|
9108
9460
|
const parsed = FacebookPageIntelBodySchema.safeParse(raw);
|
|
9109
|
-
if (!parsed.success)
|
|
9110
|
-
return c.json(invalidRequest(parsed.error.issues[0]?.message ?? "Invalid request"), 400);
|
|
9111
|
-
}
|
|
9461
|
+
if (!parsed.success) return c.json(invalidRequest(parsed.error.issues[0]?.message ?? "Invalid request"), 400);
|
|
9112
9462
|
const body = parsed.data;
|
|
9113
9463
|
const maxAds = Math.min(200, Math.max(1, body.maxAds ?? 50));
|
|
9114
9464
|
const country = body.country?.trim().toUpperCase() ?? "US";
|
|
9115
9465
|
const listingUrl = buildPageIntelUrl(body, country);
|
|
9116
9466
|
const fbUser = c.get("user");
|
|
9117
|
-
const
|
|
9118
|
-
if (!
|
|
9467
|
+
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 } });
|
|
9468
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
9119
9469
|
const driver = new BrowserDriver();
|
|
9120
|
-
let refunded = false;
|
|
9470
|
+
let refunded = false, debited = false;
|
|
9121
9471
|
try {
|
|
9472
|
+
const { ok: fbOk, balance_mc: fbBal } = await debitMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD, body.pageId ?? body.query ?? body.libraryId ?? "");
|
|
9473
|
+
if (!fbOk) return c.json(insufficientBalanceResponse(fbBal, MC_COSTS.fb_ad), 402);
|
|
9474
|
+
debited = true;
|
|
9122
9475
|
await driver.launch(await kernelLaunchOptsResidential());
|
|
9123
9476
|
await driver.navigateTo(listingUrl);
|
|
9124
9477
|
const extractor = new FacebookAdExtractor(driver);
|
|
@@ -9133,7 +9486,7 @@ facebookAdApp.post("/page-intel", createApiKeyAuth(), async (c) => {
|
|
|
9133
9486
|
return c.json(result);
|
|
9134
9487
|
} catch (err) {
|
|
9135
9488
|
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");
|
|
9489
|
+
if (debited && !refunded) await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
|
|
9137
9490
|
await logRequestEvent({ userId: fbUser.id, source: "facebook_page_intel", status: "failed", query: body.pageId ?? body.query ?? body.libraryId ?? "", error: msg });
|
|
9138
9491
|
if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
|
|
9139
9492
|
return c.json({ error: msg }, 503);
|
|
@@ -9141,6 +9494,7 @@ facebookAdApp.post("/page-intel", createApiKeyAuth(), async (c) => {
|
|
|
9141
9494
|
return c.json({ error: msg }, 500);
|
|
9142
9495
|
} finally {
|
|
9143
9496
|
await driver.close();
|
|
9497
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
9144
9498
|
}
|
|
9145
9499
|
});
|
|
9146
9500
|
facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
|
|
@@ -9156,35 +9510,83 @@ facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
|
|
|
9156
9510
|
}
|
|
9157
9511
|
const videoUrl = urlCheck.parsed.href;
|
|
9158
9512
|
const fbUser = c.get("user");
|
|
9159
|
-
const
|
|
9160
|
-
|
|
9513
|
+
const gate = await acquireConcurrencyGate(fbUser, "facebook_transcribe", {
|
|
9514
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
9515
|
+
metadata: { videoUrl }
|
|
9516
|
+
});
|
|
9517
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
9161
9518
|
fal2.config({ credentials: process.env.FAL_KEY });
|
|
9519
|
+
let debited = false;
|
|
9162
9520
|
try {
|
|
9163
|
-
const
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
|
|
9167
|
-
|
|
9168
|
-
|
|
9169
|
-
const data = result.data;
|
|
9170
|
-
const text = data.text ?? "";
|
|
9171
|
-
const chunks = data.chunks ?? [];
|
|
9172
|
-
const durationMs = Date.now() - startMs;
|
|
9173
|
-
const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
|
9174
|
-
const lines = ["# Facebook Ad Transcript", "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
|
|
9175
|
-
if (chunks.length) {
|
|
9176
|
-
lines.push("## Timestamped Segments", "");
|
|
9177
|
-
for (const ch of chunks) {
|
|
9178
|
-
lines.push(`**[${fmtTs2(ch.timestamp[0])} \u2192 ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
|
|
9179
|
-
}
|
|
9180
|
-
}
|
|
9181
|
-
await logRequestEvent({ userId: fbUser.id, source: "facebook_transcribe", status: "done", query: videoUrl, resultCount: chunks.length, result: { text, chunks, durationMs } });
|
|
9182
|
-
return c.json({ text, chunks, durationMs, markdown: lines.join("\n") });
|
|
9521
|
+
const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE, videoUrl);
|
|
9522
|
+
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_transcribe), 402);
|
|
9523
|
+
debited = true;
|
|
9524
|
+
const transcript = await transcribeFacebookVideoUrl(videoUrl, "# Facebook Ad Transcript");
|
|
9525
|
+
await logRequestEvent({ userId: fbUser.id, source: "facebook_transcribe", status: "done", query: videoUrl, resultCount: transcript.chunks.length, result: { text: transcript.text, chunks: transcript.chunks, durationMs: transcript.durationMs } });
|
|
9526
|
+
return c.json(transcript);
|
|
9183
9527
|
} catch (err) {
|
|
9184
9528
|
const msg = err instanceof Error ? err.message : String(err);
|
|
9185
|
-
await creditMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE_REFUND, "failed call");
|
|
9529
|
+
if (debited) await creditMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE_REFUND, "failed call");
|
|
9186
9530
|
await logRequestEvent({ userId: fbUser.id, source: "facebook_transcribe", status: "failed", query: videoUrl, error: msg });
|
|
9187
9531
|
return c.json({ error: msg }, 500);
|
|
9532
|
+
} finally {
|
|
9533
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
9534
|
+
}
|
|
9535
|
+
});
|
|
9536
|
+
facebookAdApp.post("/video-transcribe", createApiKeyAuth(), async (c) => {
|
|
9537
|
+
const raw = await c.req.json().catch(() => ({}));
|
|
9538
|
+
const parsed = FacebookVideoTranscribeBodySchema.safeParse(raw);
|
|
9539
|
+
if (!parsed.success) {
|
|
9540
|
+
return c.json(invalidRequest(parsed.error.issues[0]?.message ?? "Invalid request"), 400);
|
|
9541
|
+
}
|
|
9542
|
+
const body = parsed.data;
|
|
9543
|
+
const urlCheck = await validatePublicHttpUrl(body.url, { field: "url", requireHttps: false });
|
|
9544
|
+
if (urlCheck.error) {
|
|
9545
|
+
return c.json(invalidRequest(urlCheck.error), 400);
|
|
9546
|
+
}
|
|
9547
|
+
const sourceUrl = urlCheck.parsed;
|
|
9548
|
+
if (!isAllowedFacebookOrganicUrl(sourceUrl)) {
|
|
9549
|
+
return c.json(invalidRequest("url must be a Facebook organic video, reel, or share URL"), 400);
|
|
9550
|
+
}
|
|
9551
|
+
const fbUser = c.get("user");
|
|
9552
|
+
const gate = await acquireConcurrencyGate(fbUser, "facebook_video_transcribe", {
|
|
9553
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
9554
|
+
metadata: { url: sourceUrl.href }
|
|
9555
|
+
});
|
|
9556
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
9557
|
+
fal2.config({ credentials: process.env.FAL_KEY });
|
|
9558
|
+
const driver = new BrowserDriver();
|
|
9559
|
+
let debited = false;
|
|
9560
|
+
try {
|
|
9561
|
+
const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE, sourceUrl.href);
|
|
9562
|
+
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_transcribe), 402);
|
|
9563
|
+
debited = true;
|
|
9564
|
+
await driver.launch(await kernelLaunchOptsResidential());
|
|
9565
|
+
await driver.navigateTo(sourceUrl.href);
|
|
9566
|
+
const page = driver.getPage();
|
|
9567
|
+
const video = await extractFacebookOrganicVideoFromPage(page, sourceUrl.href, body.quality);
|
|
9568
|
+
const transcript = await transcribeFacebookVideoUrl(video.videoUrl, "# Facebook Organic Video Transcript");
|
|
9569
|
+
const result = {
|
|
9570
|
+
...video,
|
|
9571
|
+
videoDurationSec: video.durationSec,
|
|
9572
|
+
text: transcript.text,
|
|
9573
|
+
chunks: transcript.chunks,
|
|
9574
|
+
durationMs: transcript.durationMs,
|
|
9575
|
+
markdown: transcript.markdown
|
|
9576
|
+
};
|
|
9577
|
+
await logRequestEvent({ userId: fbUser.id, source: "facebook_video_transcribe", status: "done", query: sourceUrl.href, resultCount: transcript.chunks.length, result });
|
|
9578
|
+
return c.json(result);
|
|
9579
|
+
} catch (err) {
|
|
9580
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
9581
|
+
if (debited) await creditMc(fbUser.id, MC_COSTS.fb_transcribe, LedgerOperation.FB_TRANSCRIBE_REFUND, "failed call");
|
|
9582
|
+
await logRequestEvent({ userId: fbUser.id, source: "facebook_video_transcribe", status: "failed", query: sourceUrl.href, error: msg });
|
|
9583
|
+
if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
|
|
9584
|
+
return c.json({ error: msg }, 503);
|
|
9585
|
+
}
|
|
9586
|
+
return c.json({ error: msg }, 500);
|
|
9587
|
+
} finally {
|
|
9588
|
+
await driver.close();
|
|
9589
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
9188
9590
|
}
|
|
9189
9591
|
});
|
|
9190
9592
|
facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
@@ -9198,11 +9600,18 @@ facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
|
9198
9600
|
const maxResults = Math.min(20, Math.max(1, body.maxResults ?? 10));
|
|
9199
9601
|
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
9602
|
const fbUser = c.get("user");
|
|
9201
|
-
const
|
|
9202
|
-
|
|
9603
|
+
const gate = await acquireConcurrencyGate(fbUser, "facebook_search", {
|
|
9604
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
9605
|
+
metadata: { query: body.query.trim(), country }
|
|
9606
|
+
});
|
|
9607
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
9203
9608
|
const driver = new BrowserDriver();
|
|
9204
9609
|
let searchRefunded = false;
|
|
9610
|
+
let debited = false;
|
|
9205
9611
|
try {
|
|
9612
|
+
const { ok, balance_mc } = await debitMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH, body.query.trim());
|
|
9613
|
+
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.fb_search), 402);
|
|
9614
|
+
debited = true;
|
|
9206
9615
|
await driver.launch(await kernelLaunchOptsResidential());
|
|
9207
9616
|
const page = driver.getPage();
|
|
9208
9617
|
const collated = await collectAdLibraryResults(page, searchUrl, Math.max(maxResults * 4, 40));
|
|
@@ -9263,7 +9672,7 @@ facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
|
9263
9672
|
return c.json(searchResult);
|
|
9264
9673
|
} catch (err) {
|
|
9265
9674
|
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");
|
|
9675
|
+
if (debited && !searchRefunded) await creditMc(fbUser.id, MC_COSTS.fb_search, LedgerOperation.FB_SEARCH_REFUND, "failed call");
|
|
9267
9676
|
await logRequestEvent({ userId: fbUser.id, source: "facebook_search", status: "failed", query: body.query.trim(), error: msg });
|
|
9268
9677
|
if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) {
|
|
9269
9678
|
return c.json({ error: msg }, 503);
|
|
@@ -9271,6 +9680,7 @@ facebookAdApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
|
9271
9680
|
return c.json({ error: msg }, 500);
|
|
9272
9681
|
} finally {
|
|
9273
9682
|
await driver.close();
|
|
9683
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
9274
9684
|
}
|
|
9275
9685
|
});
|
|
9276
9686
|
var ALLOWED_MEDIA_HOSTS = ["fbcdn.net", "cdninstagram.com", "scontent.facebook.com", "scontent.cdninstagram.com"];
|
|
@@ -10129,14 +10539,21 @@ mapsApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
|
10129
10539
|
if (!parsed.success) {
|
|
10130
10540
|
return c.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, 400);
|
|
10131
10541
|
}
|
|
10132
|
-
const
|
|
10133
|
-
|
|
10134
|
-
|
|
10135
|
-
|
|
10136
|
-
|
|
10137
|
-
|
|
10138
|
-
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_search), 402);
|
|
10542
|
+
const gate = await acquireConcurrencyGate(user, "maps_search", {
|
|
10543
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
10544
|
+
metadata: { query: parsed.data.query, location: parsed.data.location }
|
|
10545
|
+
});
|
|
10546
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
10547
|
+
let debited = false;
|
|
10139
10548
|
try {
|
|
10549
|
+
const { ok, balance_mc } = await debitMc(
|
|
10550
|
+
user.id,
|
|
10551
|
+
MC_COSTS.maps_search,
|
|
10552
|
+
LedgerOperation.MAPS_SEARCH,
|
|
10553
|
+
[parsed.data.query, parsed.data.location].filter(Boolean).join(" ")
|
|
10554
|
+
);
|
|
10555
|
+
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_search), 402);
|
|
10556
|
+
debited = true;
|
|
10140
10557
|
const result = await runMapsSearchWithRotation({
|
|
10141
10558
|
...parsed.data,
|
|
10142
10559
|
configuredKernelProxyId: browserServiceProxyId()
|
|
@@ -10152,7 +10569,7 @@ mapsApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
|
10152
10569
|
});
|
|
10153
10570
|
return c.json(result);
|
|
10154
10571
|
} catch (err) {
|
|
10155
|
-
await creditMc(user.id, MC_COSTS.maps_search, LedgerOperation.REFUND, "failed maps_search call");
|
|
10572
|
+
if (debited) await creditMc(user.id, MC_COSTS.maps_search, LedgerOperation.REFUND, "failed maps_search call");
|
|
10156
10573
|
const msg = err instanceof Error ? err.message : String(err);
|
|
10157
10574
|
await logRequestEvent({
|
|
10158
10575
|
userId: user.id,
|
|
@@ -10164,6 +10581,8 @@ mapsApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
|
10164
10581
|
result: err instanceof MapsSearchRotationError ? { attempts: err.attempts } : void 0
|
|
10165
10582
|
});
|
|
10166
10583
|
return mapsErrorResponse(c, err, "maps_search_failed");
|
|
10584
|
+
} finally {
|
|
10585
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
10167
10586
|
}
|
|
10168
10587
|
});
|
|
10169
10588
|
mapsApp.post("/place", createApiKeyAuth(), async (c) => {
|
|
@@ -10176,17 +10595,24 @@ mapsApp.post("/place", createApiKeyAuth(), async (c) => {
|
|
|
10176
10595
|
if (!parsed.success) {
|
|
10177
10596
|
return c.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, 400);
|
|
10178
10597
|
}
|
|
10179
|
-
const
|
|
10180
|
-
|
|
10181
|
-
|
|
10182
|
-
|
|
10183
|
-
|
|
10184
|
-
);
|
|
10185
|
-
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_place), 402);
|
|
10598
|
+
const gate = await acquireConcurrencyGate(user, "maps_place", {
|
|
10599
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
10600
|
+
metadata: { businessName: parsed.data.businessName, location: parsed.data.location }
|
|
10601
|
+
});
|
|
10602
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
10186
10603
|
const driver = new BrowserDriver();
|
|
10187
10604
|
const extractor = new MapsExtractor(driver);
|
|
10188
10605
|
let reviewDebitMc = 0;
|
|
10606
|
+
let debited = false;
|
|
10189
10607
|
try {
|
|
10608
|
+
const { ok, balance_mc } = await debitMc(
|
|
10609
|
+
user.id,
|
|
10610
|
+
MC_COSTS.maps_place,
|
|
10611
|
+
LedgerOperation.MAPS_PLACE,
|
|
10612
|
+
`${parsed.data.businessName} ${parsed.data.location}`
|
|
10613
|
+
);
|
|
10614
|
+
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.maps_place), 402);
|
|
10615
|
+
debited = true;
|
|
10190
10616
|
const result = await extractor.extract(parsed.data);
|
|
10191
10617
|
const reviewCount = Array.isArray(result.reviews) ? result.reviews.length : 0;
|
|
10192
10618
|
if (reviewCount > 0) {
|
|
@@ -10214,7 +10640,7 @@ mapsApp.post("/place", createApiKeyAuth(), async (c) => {
|
|
|
10214
10640
|
});
|
|
10215
10641
|
return c.json(result);
|
|
10216
10642
|
} catch (err) {
|
|
10217
|
-
await creditMc(user.id, MC_COSTS.maps_place, LedgerOperation.REFUND, "failed maps_place call");
|
|
10643
|
+
if (debited) await creditMc(user.id, MC_COSTS.maps_place, LedgerOperation.REFUND, "failed maps_place call");
|
|
10218
10644
|
if (reviewDebitMc > 0) {
|
|
10219
10645
|
await creditMc(
|
|
10220
10646
|
user.id,
|
|
@@ -10235,6 +10661,7 @@ mapsApp.post("/place", createApiKeyAuth(), async (c) => {
|
|
|
10235
10661
|
return mapsErrorResponse(c, msg, "maps_place_failed");
|
|
10236
10662
|
} finally {
|
|
10237
10663
|
await driver.close();
|
|
10664
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
10238
10665
|
}
|
|
10239
10666
|
});
|
|
10240
10667
|
|
|
@@ -10658,18 +11085,27 @@ directoryApp.post("/run", createApiKeyAuth(), async (c) => {
|
|
|
10658
11085
|
if (!kernelApiKey && parsed.data.proxyMode !== "none") {
|
|
10659
11086
|
return c.json({ error: "Browser service API key is required for directory workflow Maps searches unless proxyMode is none" }, 503);
|
|
10660
11087
|
}
|
|
10661
|
-
const
|
|
10662
|
-
|
|
10663
|
-
|
|
10664
|
-
|
|
10665
|
-
|
|
10666
|
-
|
|
10667
|
-
|
|
10668
|
-
|
|
10669
|
-
);
|
|
10670
|
-
if (!debit.ok) return c.json(insufficientBalanceResponse(debit.balance_mc, requiredMc), 402);
|
|
10671
|
-
}
|
|
11088
|
+
const gate = await acquireConcurrencyGate(user, "directory_workflow", {
|
|
11089
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
11090
|
+
ttlSeconds: 30 * 60,
|
|
11091
|
+
metadata: { query: parsed.data.query, state: parsed.data.state }
|
|
11092
|
+
});
|
|
11093
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
11094
|
+
let debited = false;
|
|
11095
|
+
let requiredMc = 0;
|
|
10672
11096
|
try {
|
|
11097
|
+
const plan = await resolveDirectoryMarkets(parsed.data);
|
|
11098
|
+
requiredMc = plan.markets.length * MC_COSTS.maps_search;
|
|
11099
|
+
if (requiredMc > 0) {
|
|
11100
|
+
const debit = await debitMc(
|
|
11101
|
+
user.id,
|
|
11102
|
+
requiredMc,
|
|
11103
|
+
LedgerOperation.MAPS_SEARCH,
|
|
11104
|
+
`directory_workflow ${parsed.data.query} ${parsed.data.state} ${plan.markets.length} cities`
|
|
11105
|
+
);
|
|
11106
|
+
if (!debit.ok) return c.json(insufficientBalanceResponse(debit.balance_mc, requiredMc), 402);
|
|
11107
|
+
debited = true;
|
|
11108
|
+
}
|
|
10673
11109
|
const result = await runDirectoryWorkflowFromPlan(parsed.data, plan);
|
|
10674
11110
|
const failedCities = result.cities.filter((city) => city.status === "failed").length;
|
|
10675
11111
|
if (failedCities > 0) {
|
|
@@ -10686,7 +11122,7 @@ directoryApp.post("/run", createApiKeyAuth(), async (c) => {
|
|
|
10686
11122
|
});
|
|
10687
11123
|
return c.json(result);
|
|
10688
11124
|
} catch (err) {
|
|
10689
|
-
if (requiredMc > 0) {
|
|
11125
|
+
if (debited && requiredMc > 0) {
|
|
10690
11126
|
await creditMc(user.id, requiredMc, LedgerOperation.REFUND, "failed directory_workflow call");
|
|
10691
11127
|
}
|
|
10692
11128
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -10699,6 +11135,8 @@ directoryApp.post("/run", createApiKeyAuth(), async (c) => {
|
|
|
10699
11135
|
error: message
|
|
10700
11136
|
});
|
|
10701
11137
|
return c.json({ error: message, error_code: "directory_workflow_failed", retryable: true }, 500);
|
|
11138
|
+
} finally {
|
|
11139
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
10702
11140
|
}
|
|
10703
11141
|
});
|
|
10704
11142
|
|
|
@@ -10845,7 +11283,8 @@ async function executeWorkflowRun(input) {
|
|
|
10845
11283
|
apiKey: input.user.api_key,
|
|
10846
11284
|
apiUrl: input.apiUrl,
|
|
10847
11285
|
outputDir: hostedWorkflowOutputDir(),
|
|
10848
|
-
runId: input.runId
|
|
11286
|
+
runId: input.runId,
|
|
11287
|
+
headers: input.concurrencyLockId ? { "x-mcp-scraper-concurrency-lock": input.concurrencyLockId } : void 0
|
|
10849
11288
|
});
|
|
10850
11289
|
const manifest = await readManifestFromSummary(summary);
|
|
10851
11290
|
await completeWorkflowRunRecord(input.runId, summary.status, manifest ?? { summary });
|
|
@@ -10878,20 +11317,29 @@ workflowApp.post("/run", createApiKeyAuth(), async (c) => {
|
|
|
10878
11317
|
} catch (err) {
|
|
10879
11318
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
|
|
10880
11319
|
}
|
|
10881
|
-
const
|
|
11320
|
+
const gate = await acquireConcurrencyGate(user, "workflow_run", {
|
|
11321
|
+
ttlSeconds: 60 * 60,
|
|
11322
|
+
metadata: { workflowId: parsed.data.workflowId }
|
|
11323
|
+
});
|
|
11324
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
11325
|
+
let run = null;
|
|
10882
11326
|
try {
|
|
11327
|
+
run = await createWorkflowRun({ userId: user.id, workflowId: parsed.data.workflowId, workflowInput: input });
|
|
10883
11328
|
const executed = await executeWorkflowRun({
|
|
10884
11329
|
runId: run.id,
|
|
10885
11330
|
user,
|
|
10886
11331
|
workflowId: parsed.data.workflowId,
|
|
10887
11332
|
workflowInput: input,
|
|
10888
11333
|
apiUrl: originFromUrl(c.req.url),
|
|
10889
|
-
webhookUrl
|
|
11334
|
+
webhookUrl,
|
|
11335
|
+
concurrencyLockId: gate.lockId
|
|
10890
11336
|
});
|
|
10891
11337
|
return c.json({ run: exposeRun(c, executed.run, executed.artifacts), summary: executed.summary });
|
|
10892
11338
|
} catch (err) {
|
|
10893
|
-
const failed = await getWorkflowRun(run.id, user.id);
|
|
11339
|
+
const failed = run ? await getWorkflowRun(run.id, user.id) : null;
|
|
10894
11340
|
return c.json({ run: failed, error: err instanceof Error ? err.message : String(err) }, 500);
|
|
11341
|
+
} finally {
|
|
11342
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
10895
11343
|
}
|
|
10896
11344
|
});
|
|
10897
11345
|
workflowApp.get("/runs", createApiKeyAuth(), async (c) => {
|
|
@@ -10989,8 +11437,14 @@ workflowApp.post("/schedules/:id/run", createApiKeyAuth(), async (c) => {
|
|
|
10989
11437
|
const user = c.get("user");
|
|
10990
11438
|
const schedule = await getWorkflowSchedule(c.req.param("id"), user.id);
|
|
10991
11439
|
if (!schedule) return c.json({ error: "Schedule not found" }, 404);
|
|
10992
|
-
const
|
|
11440
|
+
const gate = await acquireConcurrencyGate(user, "workflow_schedule_run", {
|
|
11441
|
+
ttlSeconds: 60 * 60,
|
|
11442
|
+
metadata: { scheduleId: schedule.id, workflowId: schedule.workflow_id }
|
|
11443
|
+
});
|
|
11444
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
11445
|
+
let run = null;
|
|
10993
11446
|
try {
|
|
11447
|
+
run = await createWorkflowRun({ userId: user.id, scheduleId: schedule.id, workflowId: schedule.workflow_id, workflowInput: schedule.input });
|
|
10994
11448
|
const executed = await executeWorkflowRun({
|
|
10995
11449
|
runId: run.id,
|
|
10996
11450
|
user,
|
|
@@ -10998,12 +11452,15 @@ workflowApp.post("/schedules/:id/run", createApiKeyAuth(), async (c) => {
|
|
|
10998
11452
|
workflowInput: schedule.input,
|
|
10999
11453
|
apiUrl: originFromUrl(c.req.url),
|
|
11000
11454
|
scheduleId: schedule.id,
|
|
11001
|
-
webhookUrl: schedule.webhook_url
|
|
11455
|
+
webhookUrl: schedule.webhook_url,
|
|
11456
|
+
concurrencyLockId: gate.lockId
|
|
11002
11457
|
});
|
|
11003
11458
|
return c.json({ run: exposeRun(c, executed.run, executed.artifacts), summary: executed.summary });
|
|
11004
11459
|
} catch (err) {
|
|
11005
|
-
const failed = await getWorkflowRun(run.id, user.id);
|
|
11460
|
+
const failed = run ? await getWorkflowRun(run.id, user.id) : null;
|
|
11006
11461
|
return c.json({ run: failed, error: err instanceof Error ? err.message : String(err) }, 500);
|
|
11462
|
+
} finally {
|
|
11463
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
11007
11464
|
}
|
|
11008
11465
|
});
|
|
11009
11466
|
async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
|
|
@@ -11017,19 +11474,28 @@ async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
|
|
|
11017
11474
|
continue;
|
|
11018
11475
|
}
|
|
11019
11476
|
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}`
|
|
11477
|
+
const gate = await acquireConcurrencyGate(user, "workflow_schedule_dispatch", {
|
|
11478
|
+
ttlSeconds: 60 * 60,
|
|
11479
|
+
metadata: { scheduleId: schedule.id, workflowId: schedule.workflow_id }
|
|
11026
11480
|
});
|
|
11027
|
-
|
|
11028
|
-
|
|
11029
|
-
results.push({ scheduleId: schedule.id, runId: run.id, status: run.status });
|
|
11481
|
+
if (!gate.ok) {
|
|
11482
|
+
results.push({ scheduleId: schedule.id, status: "deferred", error: concurrencyLimitExceededResponse(gate).message });
|
|
11030
11483
|
continue;
|
|
11031
11484
|
}
|
|
11485
|
+
let run = null;
|
|
11032
11486
|
try {
|
|
11487
|
+
run = await createWorkflowRun({
|
|
11488
|
+
userId: user.id,
|
|
11489
|
+
scheduleId: schedule.id,
|
|
11490
|
+
workflowId: schedule.workflow_id,
|
|
11491
|
+
workflowInput: schedule.input,
|
|
11492
|
+
idempotencyKey: `${schedule.id}:${scheduledFor}`
|
|
11493
|
+
});
|
|
11494
|
+
await markWorkflowScheduleRan(schedule.id, now, addCadence(scheduledFor, schedule.cadence));
|
|
11495
|
+
if (run.status !== "queued") {
|
|
11496
|
+
results.push({ scheduleId: schedule.id, runId: run.id, status: run.status });
|
|
11497
|
+
continue;
|
|
11498
|
+
}
|
|
11033
11499
|
const executed = await executeWorkflowRun({
|
|
11034
11500
|
runId: run.id,
|
|
11035
11501
|
user,
|
|
@@ -11037,11 +11503,14 @@ async function dispatchDueWorkflowSchedules(apiUrl, limit = 3) {
|
|
|
11037
11503
|
workflowInput: schedule.input,
|
|
11038
11504
|
apiUrl,
|
|
11039
11505
|
scheduleId: schedule.id,
|
|
11040
|
-
webhookUrl: schedule.webhook_url
|
|
11506
|
+
webhookUrl: schedule.webhook_url,
|
|
11507
|
+
concurrencyLockId: gate.lockId
|
|
11041
11508
|
});
|
|
11042
11509
|
results.push({ scheduleId: schedule.id, runId: executed.run.id, status: executed.run.status });
|
|
11043
11510
|
} catch (err) {
|
|
11044
|
-
results.push({ scheduleId: schedule.id, runId: run
|
|
11511
|
+
results.push({ scheduleId: schedule.id, runId: run?.id, status: "failed", error: err instanceof Error ? err.message : String(err) });
|
|
11512
|
+
} finally {
|
|
11513
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
11045
11514
|
}
|
|
11046
11515
|
}
|
|
11047
11516
|
return { dispatched: results.length, results };
|
|
@@ -11803,26 +12272,6 @@ async function enforceSerpIntelligenceRateLimit(c, userId) {
|
|
|
11803
12272
|
{ "Retry-After": String(result.resetSeconds) }
|
|
11804
12273
|
);
|
|
11805
12274
|
}
|
|
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
12275
|
function pageSnapshotTargetsFromBody(body) {
|
|
11827
12276
|
return body.targets?.length ? body.targets : body.urls.map((url) => ({
|
|
11828
12277
|
url,
|
|
@@ -11838,17 +12287,22 @@ serpIntelligenceApp.post("/capture", async (c) => {
|
|
|
11838
12287
|
if (!parsed.success) return c.json(formatZodError(parsed.error), 400);
|
|
11839
12288
|
const limited = await enforceSerpIntelligenceRateLimit(c, user.id);
|
|
11840
12289
|
if (limited) return limited;
|
|
11841
|
-
const
|
|
11842
|
-
|
|
12290
|
+
const gate = await acquireConcurrencyGate(user, "serp_intelligence_capture", {
|
|
12291
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
12292
|
+
metadata: { query: parsed.data.query, location: parsed.data.location }
|
|
12293
|
+
});
|
|
12294
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
11843
12295
|
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);
|
|
12296
|
+
let debited = false;
|
|
11851
12297
|
try {
|
|
12298
|
+
const { ok, balance_mc } = await debitMc(
|
|
12299
|
+
user.id,
|
|
12300
|
+
cost,
|
|
12301
|
+
LedgerOperation.SERP,
|
|
12302
|
+
parsed.data.query
|
|
12303
|
+
);
|
|
12304
|
+
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, cost), 402);
|
|
12305
|
+
debited = true;
|
|
11852
12306
|
const result = await captureSerpIntelligenceSnapshot(parsed.data, {
|
|
11853
12307
|
kernelApiKey: browserServiceApiKey(),
|
|
11854
12308
|
kernelProxyId: browserServiceProxyId(),
|
|
@@ -11866,7 +12320,7 @@ serpIntelligenceApp.post("/capture", async (c) => {
|
|
|
11866
12320
|
});
|
|
11867
12321
|
return c.json(result);
|
|
11868
12322
|
} catch (error) {
|
|
11869
|
-
await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence capture");
|
|
12323
|
+
if (debited) await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence capture");
|
|
11870
12324
|
const body = error instanceof SerpIntelligenceCaptureError ? error.toJSON() : structuredError({
|
|
11871
12325
|
error_code: "capture_failed",
|
|
11872
12326
|
error_type: "capture_error",
|
|
@@ -11883,6 +12337,8 @@ serpIntelligenceApp.post("/capture", async (c) => {
|
|
|
11883
12337
|
});
|
|
11884
12338
|
const status = error instanceof SerpIntelligenceCaptureError ? error.httpStatus : 500;
|
|
11885
12339
|
return c.json(body, status);
|
|
12340
|
+
} finally {
|
|
12341
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
11886
12342
|
}
|
|
11887
12343
|
});
|
|
11888
12344
|
serpIntelligenceApp.post("/page-snapshots", async (c) => {
|
|
@@ -11893,18 +12349,23 @@ serpIntelligenceApp.post("/page-snapshots", async (c) => {
|
|
|
11893
12349
|
if (!parsed.success) return c.json(formatZodError(parsed.error), 400);
|
|
11894
12350
|
const limited = await enforceSerpIntelligenceRateLimit(c, user.id);
|
|
11895
12351
|
if (limited) return limited;
|
|
11896
|
-
const concurrencyLimited = await enforceSerpIntelligenceConcurrency(user);
|
|
11897
|
-
if (concurrencyLimited) return concurrencyLimited;
|
|
11898
12352
|
const targets = pageSnapshotTargetsFromBody(parsed.data);
|
|
12353
|
+
const gate = await acquireConcurrencyGate(user, "serp_intelligence_page_snapshots", {
|
|
12354
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
12355
|
+
metadata: { targets: targets.length }
|
|
12356
|
+
});
|
|
12357
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
11899
12358
|
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);
|
|
12359
|
+
let debited = false;
|
|
11907
12360
|
try {
|
|
12361
|
+
const { ok, balance_mc } = await debitMc(
|
|
12362
|
+
user.id,
|
|
12363
|
+
cost,
|
|
12364
|
+
LedgerOperation.EXTRACT_URL,
|
|
12365
|
+
`serp intelligence page snapshots: ${targets.length}`
|
|
12366
|
+
);
|
|
12367
|
+
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, cost), 402);
|
|
12368
|
+
debited = true;
|
|
11908
12369
|
const result = await capturePageSnapshots(targets, {
|
|
11909
12370
|
kernelApiKey: browserServiceApiKey(),
|
|
11910
12371
|
timeoutMs: parsed.data.timeoutMs,
|
|
@@ -11921,7 +12382,7 @@ serpIntelligenceApp.post("/page-snapshots", async (c) => {
|
|
|
11921
12382
|
});
|
|
11922
12383
|
return c.json(result);
|
|
11923
12384
|
} catch (error) {
|
|
11924
|
-
await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence page snapshots");
|
|
12385
|
+
if (debited) await creditMc(user.id, cost, LedgerOperation.REFUND, "failed serp intelligence page snapshots");
|
|
11925
12386
|
const body = structuredError({
|
|
11926
12387
|
error_code: "page_snapshot_failed",
|
|
11927
12388
|
error_type: "capture_error",
|
|
@@ -11936,6 +12397,8 @@ serpIntelligenceApp.post("/page-snapshots", async (c) => {
|
|
|
11936
12397
|
error: body.message
|
|
11937
12398
|
});
|
|
11938
12399
|
return c.json(body, 500);
|
|
12400
|
+
} finally {
|
|
12401
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
11939
12402
|
}
|
|
11940
12403
|
});
|
|
11941
12404
|
|
|
@@ -12009,7 +12472,7 @@ mcpApp.all("/", async (c) => {
|
|
|
12009
12472
|
import { Hono as Hono10 } from "hono";
|
|
12010
12473
|
|
|
12011
12474
|
// src/api/browser-agent-db.ts
|
|
12012
|
-
import { randomUUID } from "crypto";
|
|
12475
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
12013
12476
|
var _ready = false;
|
|
12014
12477
|
async function migrateBrowserAgent() {
|
|
12015
12478
|
if (_ready) return;
|
|
@@ -12023,6 +12486,7 @@ async function migrateBrowserAgent() {
|
|
|
12023
12486
|
status TEXT NOT NULL DEFAULT 'open',
|
|
12024
12487
|
label TEXT,
|
|
12025
12488
|
user_id INTEGER,
|
|
12489
|
+
concurrency_lock_id TEXT,
|
|
12026
12490
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
12027
12491
|
closed_at TEXT,
|
|
12028
12492
|
last_action_at TEXT,
|
|
@@ -12032,6 +12496,11 @@ async function migrateBrowserAgent() {
|
|
|
12032
12496
|
`);
|
|
12033
12497
|
await db.execute(`CREATE INDEX IF NOT EXISTS browser_agent_sessions_status ON browser_agent_sessions(status)`);
|
|
12034
12498
|
await db.execute(`CREATE INDEX IF NOT EXISTS browser_agent_sessions_user ON browser_agent_sessions(user_id)`);
|
|
12499
|
+
try {
|
|
12500
|
+
await db.execute(`ALTER TABLE browser_agent_sessions ADD COLUMN concurrency_lock_id TEXT`);
|
|
12501
|
+
} catch {
|
|
12502
|
+
}
|
|
12503
|
+
await db.execute(`CREATE INDEX IF NOT EXISTS browser_agent_sessions_concurrency_lock ON browser_agent_sessions(concurrency_lock_id)`);
|
|
12035
12504
|
await db.execute(`
|
|
12036
12505
|
CREATE TABLE IF NOT EXISTS browser_agent_actions (
|
|
12037
12506
|
id TEXT PRIMARY KEY,
|
|
@@ -12059,11 +12528,11 @@ async function migrateBrowserAgent() {
|
|
|
12059
12528
|
}
|
|
12060
12529
|
async function createSessionRow(input) {
|
|
12061
12530
|
const db = getDb();
|
|
12062
|
-
const id = `bas_${
|
|
12531
|
+
const id = `bas_${randomUUID2().replace(/-/g, "").slice(0, 20)}`;
|
|
12063
12532
|
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]
|
|
12533
|
+
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)
|
|
12534
|
+
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, datetime('now'))`,
|
|
12535
|
+
args: [id, input.runtimeSessionId, input.liveViewUrl, input.cdpWsUrl, input.label, input.userId, input.concurrencyLockId ?? null]
|
|
12067
12536
|
});
|
|
12068
12537
|
const row = await getSessionRow(id);
|
|
12069
12538
|
if (!row) throw new Error("session insert failed");
|
|
@@ -12120,7 +12589,7 @@ async function recordAction(input) {
|
|
|
12120
12589
|
sql: `INSERT INTO browser_agent_actions (id, session_id, type, params_json, ok, error)
|
|
12121
12590
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
12122
12591
|
args: [
|
|
12123
|
-
`baa_${
|
|
12592
|
+
`baa_${randomUUID2().replace(/-/g, "").slice(0, 20)}`,
|
|
12124
12593
|
input.sessionId,
|
|
12125
12594
|
input.type,
|
|
12126
12595
|
input.params == null ? null : JSON.stringify(input.params),
|
|
@@ -12469,6 +12938,13 @@ async function replayList(runtimeSessionId) {
|
|
|
12469
12938
|
|
|
12470
12939
|
// src/api/browser-agent-routes.ts
|
|
12471
12940
|
var auth = createApiKeyAuth();
|
|
12941
|
+
var DEFAULT_BROWSER_SESSION_LOCK_TTL_SECONDS = 2 * 60 * 60;
|
|
12942
|
+
function browserSessionLockTtlSeconds(timeoutSeconds) {
|
|
12943
|
+
if (typeof timeoutSeconds === "number" && Number.isFinite(timeoutSeconds) && timeoutSeconds > 0) {
|
|
12944
|
+
return Math.max(30 * 60, Math.min(Math.trunc(timeoutSeconds) + 5 * 60, 24 * 60 * 60));
|
|
12945
|
+
}
|
|
12946
|
+
return DEFAULT_BROWSER_SESSION_LOCK_TTL_SECONDS;
|
|
12947
|
+
}
|
|
12472
12948
|
async function charge(sessionId, userId, startedAtMs) {
|
|
12473
12949
|
const elapsedMs = Date.now() - startedAtMs;
|
|
12474
12950
|
const { active_ms, billed_mc } = await addActiveMs(sessionId, elapsedMs);
|
|
@@ -12524,6 +13000,8 @@ async function loadOpenSession(id, userId) {
|
|
|
12524
13000
|
const row = await getSessionRow(id);
|
|
12525
13001
|
if (!row) return null;
|
|
12526
13002
|
if (row.user_id != null && row.user_id !== userId) return null;
|
|
13003
|
+
if (row.status !== "open") return null;
|
|
13004
|
+
await extendConcurrencyGate(row.concurrency_lock_id, DEFAULT_BROWSER_SESSION_LOCK_TTL_SECONDS);
|
|
12527
13005
|
return row;
|
|
12528
13006
|
}
|
|
12529
13007
|
function buildBrowserAgentRoutes() {
|
|
@@ -12539,23 +13017,39 @@ function buildBrowserAgentRoutes() {
|
|
|
12539
13017
|
return c.json(insufficientBalanceResponse(Number(user.balance_mc ?? 0), BROWSER_OPEN_MIN_BALANCE_MC), 402);
|
|
12540
13018
|
}
|
|
12541
13019
|
const body = await c.req.json().catch(() => ({}));
|
|
13020
|
+
const timeoutSeconds = typeof body.timeout_seconds === "number" ? body.timeout_seconds : void 0;
|
|
13021
|
+
const gate = await acquireConcurrencyGate(user, "browser_agent_session", {
|
|
13022
|
+
ttlSeconds: browserSessionLockTtlSeconds(timeoutSeconds),
|
|
13023
|
+
metadata: { label: typeof body.label === "string" ? body.label : null }
|
|
13024
|
+
});
|
|
13025
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
13026
|
+
let runtimeSessionId = null;
|
|
12542
13027
|
try {
|
|
12543
13028
|
const created = await createSession({
|
|
12544
|
-
timeoutSeconds
|
|
13029
|
+
timeoutSeconds,
|
|
12545
13030
|
proxyId: typeof body.proxy_id === "string" ? body.proxy_id : void 0,
|
|
12546
13031
|
profileName: typeof body.profile === "string" ? body.profile : void 0,
|
|
12547
13032
|
disableDefaultProxy: body.disable_default_proxy === true,
|
|
12548
13033
|
viewport: body.viewport && typeof body.viewport === "object" ? body.viewport : void 0
|
|
12549
13034
|
});
|
|
13035
|
+
runtimeSessionId = created.runtimeSessionId;
|
|
12550
13036
|
const row = await createSessionRow({
|
|
12551
13037
|
runtimeSessionId: created.runtimeSessionId,
|
|
12552
13038
|
liveViewUrl: created.liveViewUrl,
|
|
12553
13039
|
cdpWsUrl: created.cdpWsUrl,
|
|
12554
13040
|
label: typeof body.label === "string" ? body.label : null,
|
|
12555
|
-
userId: user.id
|
|
13041
|
+
userId: user.id,
|
|
13042
|
+
concurrencyLockId: gate.lockId
|
|
12556
13043
|
});
|
|
12557
13044
|
return c.json({ ...publicSession(row), watch_url: `/console/${row.id}` });
|
|
12558
13045
|
} catch (err) {
|
|
13046
|
+
if (runtimeSessionId) {
|
|
13047
|
+
try {
|
|
13048
|
+
await closeSession(runtimeSessionId);
|
|
13049
|
+
} catch {
|
|
13050
|
+
}
|
|
13051
|
+
}
|
|
13052
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
12559
13053
|
return c.json(failure(err), 502);
|
|
12560
13054
|
}
|
|
12561
13055
|
});
|
|
@@ -12586,6 +13080,7 @@ function buildBrowserAgentRoutes() {
|
|
|
12586
13080
|
} catch {
|
|
12587
13081
|
}
|
|
12588
13082
|
await markSessionClosed(row.id);
|
|
13083
|
+
await releaseConcurrencyGate(row.concurrency_lock_id);
|
|
12589
13084
|
return c.json({ ok: true });
|
|
12590
13085
|
});
|
|
12591
13086
|
app2.post("/sessions/:id/goto", async (c) => {
|
|
@@ -13542,9 +14037,6 @@ app.delete("/api-key", requireAllowedOrigin, sessionAuth, async (c) => {
|
|
|
13542
14037
|
await revokeApiKey(user.id);
|
|
13543
14038
|
return c.json({ ok: true });
|
|
13544
14039
|
});
|
|
13545
|
-
var BYPASS_EMAILS = new Set(
|
|
13546
|
-
(process.env.HARVEST_LIMIT_BYPASS_EMAILS ?? "").split(",").map((e) => e.trim()).filter(Boolean)
|
|
13547
|
-
);
|
|
13548
14040
|
var SYNC_HARVEST_TIMEOUT_OVERRIDE_MS = (() => {
|
|
13549
14041
|
const raw = process.env.SYNC_HARVEST_TIMEOUT_MS;
|
|
13550
14042
|
const parsed = raw === void 0 ? NaN : Number(raw);
|
|
@@ -13573,11 +14065,20 @@ function countPaaQuestions(result) {
|
|
|
13573
14065
|
function paaCostForQuestionCount(questionCount) {
|
|
13574
14066
|
return Math.max(1, questionCount) * MC_COSTS.paa;
|
|
13575
14067
|
}
|
|
13576
|
-
async function checkHarvestLimits(
|
|
13577
|
-
if (
|
|
13578
|
-
|
|
13579
|
-
const
|
|
13580
|
-
|
|
14068
|
+
async function checkHarvestLimits(user, reuseLockId) {
|
|
14069
|
+
if (reuseLockId && await reuseExistingConcurrencyGate(user.id, reuseLockId, 30 * 60)) return null;
|
|
14070
|
+
if (isConcurrencyLimitBypassed(user)) return null;
|
|
14071
|
+
const limit = concurrencyLimitForUser(user);
|
|
14072
|
+
const active = await countActiveUsageForUser(user.id);
|
|
14073
|
+
if (active >= limit) {
|
|
14074
|
+
return concurrencyLimitExceededResponse({
|
|
14075
|
+
ok: false,
|
|
14076
|
+
active,
|
|
14077
|
+
limit,
|
|
14078
|
+
operation: "harvest",
|
|
14079
|
+
retryAfterSeconds: 30
|
|
14080
|
+
});
|
|
14081
|
+
}
|
|
13581
14082
|
return null;
|
|
13582
14083
|
}
|
|
13583
14084
|
app.post("/harvest", auth2, async (c) => {
|
|
@@ -13592,8 +14093,8 @@ app.post("/harvest", auth2, async (c) => {
|
|
|
13592
14093
|
if (checked.error) return c.json({ error: checked.error }, 400);
|
|
13593
14094
|
body.callback_url = checked.parsed?.href;
|
|
13594
14095
|
}
|
|
13595
|
-
const limitErr = await checkHarvestLimits(user
|
|
13596
|
-
if (limitErr) return c.json(limitErr, 429);
|
|
14096
|
+
const limitErr = await checkHarvestLimits(user, c.req.header("x-mcp-scraper-concurrency-lock"));
|
|
14097
|
+
if (limitErr) return c.json(limitErr, 429, { "Retry-After": "30" });
|
|
13597
14098
|
const options = {
|
|
13598
14099
|
query: body.query.trim(),
|
|
13599
14100
|
location: body.location,
|
|
@@ -13628,8 +14129,8 @@ app.post("/harvest/sync", auth2, async (c) => {
|
|
|
13628
14129
|
const bodyResult = HarvestBodySchema.safeParse(raw);
|
|
13629
14130
|
if (!bodyResult.success) return c.json({ error: bodyResult.error.issues[0]?.message ?? "Invalid request" }, 400);
|
|
13630
14131
|
const body = bodyResult.data;
|
|
13631
|
-
const limitErr = await checkHarvestLimits(user
|
|
13632
|
-
if (limitErr) return c.json(limitErr, 429);
|
|
14132
|
+
const limitErr = await checkHarvestLimits(user, c.req.header("x-mcp-scraper-concurrency-lock"));
|
|
14133
|
+
if (limitErr) return c.json(limitErr, 429, { "Retry-After": "30" });
|
|
13633
14134
|
const options = {
|
|
13634
14135
|
query: body.query.trim(),
|
|
13635
14136
|
location: body.location,
|
|
@@ -13788,9 +14289,16 @@ app.post("/extract-url", auth2, async (c) => {
|
|
|
13788
14289
|
}
|
|
13789
14290
|
})();
|
|
13790
14291
|
const user = c.get("user");
|
|
13791
|
-
const
|
|
13792
|
-
|
|
14292
|
+
const gate = await acquireConcurrencyGate(user, "extract_url", {
|
|
14293
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
14294
|
+
metadata: { url: canonicalUrl }
|
|
14295
|
+
});
|
|
14296
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
14297
|
+
let debited = false;
|
|
13793
14298
|
try {
|
|
14299
|
+
const { ok: euOk, balance_mc: euBal } = await debitMc(user.id, MC_COSTS.page_scrape, LedgerOperation.EXTRACT_URL, new URL(canonicalUrl).hostname);
|
|
14300
|
+
if (!euOk) return c.json(insufficientBalanceResponse(euBal, MC_COSTS.page_scrape), 402);
|
|
14301
|
+
debited = true;
|
|
13794
14302
|
const kernelApiKey = browserServiceApiKey();
|
|
13795
14303
|
const device = screenshotDevice === "mobile" ? "mobile" : "desktop";
|
|
13796
14304
|
const [result, pageData] = await Promise.all([
|
|
@@ -13808,9 +14316,11 @@ app.post("/extract-url", auth2, async (c) => {
|
|
|
13808
14316
|
return c.json({ ...result, screenshot: screenshotMeta, branding: brandingData, media: mediaMeta });
|
|
13809
14317
|
} catch (err) {
|
|
13810
14318
|
const msg = err instanceof Error ? err.message : String(err);
|
|
13811
|
-
await creditMc(user.id, MC_COSTS.page_scrape, LedgerOperation.EXTRACT_URL, "failed call");
|
|
14319
|
+
if (debited) await creditMc(user.id, MC_COSTS.page_scrape, LedgerOperation.EXTRACT_URL, "failed call");
|
|
13812
14320
|
await logRequestEvent({ userId: user.id, source: "extract_url", status: "failed", query: canonicalUrl, error: msg });
|
|
13813
14321
|
return c.json({ error: msg }, 500);
|
|
14322
|
+
} finally {
|
|
14323
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
13814
14324
|
}
|
|
13815
14325
|
});
|
|
13816
14326
|
app.post("/map-urls", auth2, async (c) => {
|
|
@@ -13822,9 +14332,16 @@ app.post("/map-urls", auth2, async (c) => {
|
|
|
13822
14332
|
if (checked.error || !checked.parsed) return c.json({ error: checked.error ?? "Invalid URL" }, 400);
|
|
13823
14333
|
const parsed = checked.parsed;
|
|
13824
14334
|
const user = c.get("user");
|
|
13825
|
-
const
|
|
13826
|
-
|
|
14335
|
+
const gate = await acquireConcurrencyGate(user, "map_urls", {
|
|
14336
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
14337
|
+
metadata: { url: parsed.href }
|
|
14338
|
+
});
|
|
14339
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
14340
|
+
let debited = false;
|
|
13827
14341
|
try {
|
|
14342
|
+
const { ok: mapOk, balance_mc: mapBal } = await debitMc(user.id, MC_COSTS.url_map, LedgerOperation.URL_MAP, parsed.hostname);
|
|
14343
|
+
if (!mapOk) return c.json(insufficientBalanceResponse(mapBal, MC_COSTS.url_map), 402);
|
|
14344
|
+
debited = true;
|
|
13828
14345
|
const result = await spiderSite({
|
|
13829
14346
|
startUrl: parsed.href,
|
|
13830
14347
|
maxUrls: Math.min(2e3, Math.max(1, body.maxUrls ?? 500)),
|
|
@@ -13842,7 +14359,7 @@ app.post("/map-urls", auth2, async (c) => {
|
|
|
13842
14359
|
return c.json(result);
|
|
13843
14360
|
} catch (err) {
|
|
13844
14361
|
const msg = err instanceof Error ? err.message : String(err);
|
|
13845
|
-
await creditMc(user.id, MC_COSTS.url_map, LedgerOperation.URL_MAP_REFUND, "failed call");
|
|
14362
|
+
if (debited) await creditMc(user.id, MC_COSTS.url_map, LedgerOperation.URL_MAP_REFUND, "failed call");
|
|
13846
14363
|
await logRequestEvent({
|
|
13847
14364
|
userId: user.id,
|
|
13848
14365
|
source: "map_urls",
|
|
@@ -13851,6 +14368,8 @@ app.post("/map-urls", auth2, async (c) => {
|
|
|
13851
14368
|
error: msg
|
|
13852
14369
|
});
|
|
13853
14370
|
return c.json({ error: msg }, 500);
|
|
14371
|
+
} finally {
|
|
14372
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
13854
14373
|
}
|
|
13855
14374
|
});
|
|
13856
14375
|
app.post("/extract-site", auth2, async (c) => {
|
|
@@ -13863,9 +14382,16 @@ app.post("/extract-site", auth2, async (c) => {
|
|
|
13863
14382
|
const parsed = checked.parsed;
|
|
13864
14383
|
const user = c.get("user");
|
|
13865
14384
|
const siteHoldMc = MC_COSTS.page_scrape * 10;
|
|
13866
|
-
const
|
|
13867
|
-
|
|
14385
|
+
const gate = await acquireConcurrencyGate(user, "extract_site", {
|
|
14386
|
+
reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"),
|
|
14387
|
+
metadata: { url: parsed.href }
|
|
14388
|
+
});
|
|
14389
|
+
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
14390
|
+
let debited = false;
|
|
13868
14391
|
try {
|
|
14392
|
+
const { ok: siteOk, balance_mc: siteBal } = await debitMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_HOLD, parsed.hostname);
|
|
14393
|
+
if (!siteOk) return c.json(insufficientBalanceResponse(siteBal, siteHoldMc), 402);
|
|
14394
|
+
debited = true;
|
|
13869
14395
|
const result = await extractSite({
|
|
13870
14396
|
startUrl: parsed.href,
|
|
13871
14397
|
maxPages: Math.min(200, Math.max(1, body.maxPages ?? 100)),
|
|
@@ -13886,7 +14412,7 @@ app.post("/extract-site", auth2, async (c) => {
|
|
|
13886
14412
|
});
|
|
13887
14413
|
return c.json(result);
|
|
13888
14414
|
} catch (err) {
|
|
13889
|
-
await creditMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_REFUND, "failed call");
|
|
14415
|
+
if (debited) await creditMc(user.id, siteHoldMc, LedgerOperation.EXTRACT_SITE_REFUND, "failed call");
|
|
13890
14416
|
const msg = err instanceof Error ? err.message : String(err);
|
|
13891
14417
|
await logRequestEvent({
|
|
13892
14418
|
userId: user.id,
|
|
@@ -13896,6 +14422,8 @@ app.post("/extract-site", auth2, async (c) => {
|
|
|
13896
14422
|
error: msg
|
|
13897
14423
|
});
|
|
13898
14424
|
return c.json({ error: msg }, 500);
|
|
14425
|
+
} finally {
|
|
14426
|
+
await releaseConcurrencyGate(gate.lockId);
|
|
13899
14427
|
}
|
|
13900
14428
|
});
|
|
13901
14429
|
app.post("/billing/checkout", requireAllowedOrigin, sessionAuth, async (c) => {
|
|
@@ -13964,6 +14492,60 @@ app.post("/billing/concurrency/checkout", requireAllowedOrigin, sessionAuth, asy
|
|
|
13964
14492
|
return c.json({ error: message }, 500);
|
|
13965
14493
|
}
|
|
13966
14494
|
});
|
|
14495
|
+
app.post("/billing/concurrency/terminal-checkout", auth2, async (c) => {
|
|
14496
|
+
try {
|
|
14497
|
+
const user = c.get("user");
|
|
14498
|
+
const upgrade = concurrencySlotBillingInfo();
|
|
14499
|
+
if (user.concurrency_stripe_sub_id) {
|
|
14500
|
+
return c.json({
|
|
14501
|
+
error: "Already subscribed. Your account already has an active extra concurrency slot subscription.",
|
|
14502
|
+
error_code: "concurrency_subscription_exists",
|
|
14503
|
+
concurrency: {
|
|
14504
|
+
extra_slots: user.extra_concurrency_slots,
|
|
14505
|
+
limit: concurrencyLimitForUser(user),
|
|
14506
|
+
has_subscription: true,
|
|
14507
|
+
upgrade
|
|
14508
|
+
}
|
|
14509
|
+
}, 409);
|
|
14510
|
+
}
|
|
14511
|
+
const secret2 = process.env.STRIPE_SECRET_KEY?.trim();
|
|
14512
|
+
if (!secret2) return c.json({ error: "Stripe is not configured." }, 503);
|
|
14513
|
+
const stripeClient = new Stripe2(secret2, { apiVersion: STRIPE_API_VERSION });
|
|
14514
|
+
let customerId = user.stripe_customer_id;
|
|
14515
|
+
if (!customerId) {
|
|
14516
|
+
const customer = await stripeClient.customers.create({ email: user.email });
|
|
14517
|
+
customerId = customer.id;
|
|
14518
|
+
await setStripeCustomerId(user.id, customerId);
|
|
14519
|
+
}
|
|
14520
|
+
const session = await stripeClient.checkout.sessions.create({
|
|
14521
|
+
customer: customerId,
|
|
14522
|
+
mode: "subscription",
|
|
14523
|
+
line_items: [{ price: CONCURRENCY_PRICE_ID, quantity: 1 }],
|
|
14524
|
+
automatic_tax: { enabled: true },
|
|
14525
|
+
billing_address_collection: "required",
|
|
14526
|
+
customer_update: { address: "auto", name: "auto" },
|
|
14527
|
+
tax_id_collection: { enabled: true },
|
|
14528
|
+
success_url: `${appOrigin()}/billing?slot_added=1&source=terminal`,
|
|
14529
|
+
cancel_url: `${appOrigin()}/billing?slot_cancelled=1&source=terminal`
|
|
14530
|
+
});
|
|
14531
|
+
if (!session.url) return c.json({ error: "Stripe did not return a checkout URL." }, 502);
|
|
14532
|
+
return c.json({
|
|
14533
|
+
checkout_url: session.url,
|
|
14534
|
+
price: upgrade,
|
|
14535
|
+
concurrency: {
|
|
14536
|
+
current_extra_slots: user.extra_concurrency_slots,
|
|
14537
|
+
current_limit: concurrencyLimitForUser(user),
|
|
14538
|
+
after_checkout_extra_slots: user.extra_concurrency_slots + 1,
|
|
14539
|
+
after_checkout_limit: concurrencyLimitForUser({ extra_concurrency_slots: user.extra_concurrency_slots + 1 })
|
|
14540
|
+
},
|
|
14541
|
+
next_step: "Open checkout_url in a browser, complete checkout, then restart or retry the MCP request."
|
|
14542
|
+
});
|
|
14543
|
+
} catch (err) {
|
|
14544
|
+
const message = err instanceof Error ? err.message : "Unable to start terminal checkout.";
|
|
14545
|
+
console.error("[billing/concurrency/terminal-checkout]", message);
|
|
14546
|
+
return c.json({ error: message }, 500);
|
|
14547
|
+
}
|
|
14548
|
+
});
|
|
13967
14549
|
app.post("/billing/concurrency/cancel", requireAllowedOrigin, sessionAuth, async (c) => {
|
|
13968
14550
|
const user = c.get("sessionUser");
|
|
13969
14551
|
if (!user.concurrency_stripe_sub_id) return c.json({ error: "No active concurrency subscription." }, 404);
|
|
@@ -13971,9 +14553,10 @@ app.post("/billing/concurrency/cancel", requireAllowedOrigin, sessionAuth, async
|
|
|
13971
14553
|
if (!stripeSecret) return c.json({ error: "Stripe is not configured." }, 503);
|
|
13972
14554
|
const stripeClient = new Stripe2(stripeSecret, { apiVersion: STRIPE_API_VERSION });
|
|
13973
14555
|
await stripeClient.subscriptions.cancel(user.concurrency_stripe_sub_id);
|
|
13974
|
-
|
|
14556
|
+
const nextSlots = Math.max(0, user.extra_concurrency_slots - 1);
|
|
14557
|
+
await setExtraConcurrencySlots(user.id, nextSlots);
|
|
13975
14558
|
await setConcurrencySubId(user.id, null);
|
|
13976
|
-
return c.json({ ok: true, concurrency_limit:
|
|
14559
|
+
return c.json({ ok: true, extra_concurrency_slots: nextSlots, concurrency_limit: 1 + nextSlots });
|
|
13977
14560
|
});
|
|
13978
14561
|
app.get("/billing/balance", auth2, async (c) => {
|
|
13979
14562
|
const user = c.get("user");
|
|
@@ -14008,6 +14591,12 @@ app.post("/billing/credits", auth2, async (c) => {
|
|
|
14008
14591
|
item: body.item ?? null,
|
|
14009
14592
|
matched_cost: matchedCost ? (({ aliases, ...cost }) => cost)(matchedCost) : null,
|
|
14010
14593
|
costs,
|
|
14594
|
+
concurrency: {
|
|
14595
|
+
current_extra_slots: user.extra_concurrency_slots,
|
|
14596
|
+
current_limit: concurrencyLimitForUser(user),
|
|
14597
|
+
has_subscription: !!user.concurrency_stripe_sub_id,
|
|
14598
|
+
upgrade: concurrencySlotBillingInfo()
|
|
14599
|
+
},
|
|
14011
14600
|
ledger
|
|
14012
14601
|
});
|
|
14013
14602
|
});
|
|
@@ -14016,7 +14605,7 @@ app.get("/cron/tick", async (c) => {
|
|
|
14016
14605
|
if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
|
|
14017
14606
|
return c.json({ error: "Unauthorized" }, 401);
|
|
14018
14607
|
}
|
|
14019
|
-
const { drainQueue } = await import("./worker-
|
|
14608
|
+
const { drainQueue } = await import("./worker-OZSWIS3F.js");
|
|
14020
14609
|
const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
|
|
14021
14610
|
const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
|
|
14022
14611
|
const [results, sweepResult] = await Promise.all([
|
|
@@ -14144,4 +14733,4 @@ app.get("/blog/:slug/", (c) => {
|
|
|
14144
14733
|
export {
|
|
14145
14734
|
app
|
|
14146
14735
|
};
|
|
14147
|
-
//# sourceMappingURL=server-
|
|
14736
|
+
//# sourceMappingURL=server-VD2TD3AD.js.map
|