mcp-scraper 0.4.6 → 0.4.13
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/dist/bin/api-server.cjs +2446 -2148
- package/dist/bin/api-server.cjs.map +1 -1
- package/dist/bin/api-server.js +2 -2
- package/dist/bin/mcp-scraper-cli.cjs +1 -1
- package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
- package/dist/bin/mcp-scraper-cli.js +1 -1
- package/dist/bin/mcp-scraper-install.cjs +1 -1
- package/dist/bin/mcp-scraper-install.cjs.map +1 -1
- package/dist/bin/mcp-scraper-install.js +1 -1
- package/dist/bin/mcp-stdio-server.cjs +1 -1
- package/dist/bin/mcp-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-stdio-server.js +2 -2
- package/dist/bin/paa-harvest.cjs +13 -4
- package/dist/bin/paa-harvest.cjs.map +1 -1
- package/dist/bin/paa-harvest.js +1 -1
- package/dist/{chunk-5GAD2AN2.js → chunk-AUCXKRRH.js} +29 -10
- package/dist/chunk-AUCXKRRH.js.map +1 -0
- package/dist/{chunk-IFXACD4K.js → chunk-EA2AANFI.js} +2 -2
- package/dist/chunk-O2D5HGKF.js +7 -0
- package/dist/chunk-O2D5HGKF.js.map +1 -0
- package/dist/{chunk-IVQYNY45.js → chunk-SIPXEFTR.js} +36 -15
- package/dist/chunk-SIPXEFTR.js.map +1 -0
- package/dist/{chunk-NYAWN7CZ.js → chunk-VQV7MB2S.js} +2 -2
- package/dist/index.cjs +59 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/{server-R3KVQNOT.js → server-HNZG36IP.js} +576 -333
- package/dist/server-HNZG36IP.js.map +1 -0
- package/dist/{site-extract-repository-LI2S3S6E.js → site-extract-repository-6X2C7RLF.js} +3 -3
- package/dist/{worker-VASAJ7FZ.js → worker-WCSKUAOT.js} +16 -12
- package/dist/worker-WCSKUAOT.js.map +1 -0
- package/package.json +1 -2
- package/dist/chunk-5BB6Y3BB.js +0 -7
- package/dist/chunk-5BB6Y3BB.js.map +0 -1
- package/dist/chunk-5GAD2AN2.js.map +0 -1
- package/dist/chunk-IVQYNY45.js.map +0 -1
- package/dist/server-R3KVQNOT.js.map +0 -1
- package/dist/worker-VASAJ7FZ.js.map +0 -1
- /package/dist/{chunk-IFXACD4K.js.map → chunk-EA2AANFI.js.map} +0 -0
- /package/dist/{chunk-NYAWN7CZ.js.map → chunk-VQV7MB2S.js.map} +0 -0
- /package/dist/{site-extract-repository-LI2S3S6E.js.map → site-extract-repository-6X2C7RLF.js.map} +0 -0
|
@@ -11,12 +11,14 @@ import {
|
|
|
11
11
|
currentCostContext,
|
|
12
12
|
deleteKernelProxyId,
|
|
13
13
|
harvest,
|
|
14
|
+
kernelCostUsd,
|
|
14
15
|
migrateCostTelemetry,
|
|
15
16
|
recordKernelSession,
|
|
16
17
|
recordVendorUsage,
|
|
17
18
|
resolveKernelProxyId,
|
|
18
|
-
runWithCostContext
|
|
19
|
-
|
|
19
|
+
runWithCostContext,
|
|
20
|
+
vendorCostUsd
|
|
21
|
+
} from "./chunk-AUCXKRRH.js";
|
|
20
22
|
import {
|
|
21
23
|
HttpMcpToolExecutor,
|
|
22
24
|
MemoryMcpToolExecutor,
|
|
@@ -31,7 +33,7 @@ import {
|
|
|
31
33
|
registerSerpIntelligenceCaptureTools,
|
|
32
34
|
sanitizeAttempts,
|
|
33
35
|
sanitizeHarvestResult
|
|
34
|
-
} from "./chunk-
|
|
36
|
+
} from "./chunk-EA2AANFI.js";
|
|
35
37
|
import {
|
|
36
38
|
auditImages,
|
|
37
39
|
getBlobStore
|
|
@@ -65,7 +67,7 @@ import {
|
|
|
65
67
|
RawMapsOverviewSchema,
|
|
66
68
|
RawMapsReviewStatsSchema
|
|
67
69
|
} from "./chunk-XGIPATLV.js";
|
|
68
|
-
import "./chunk-
|
|
70
|
+
import "./chunk-O2D5HGKF.js";
|
|
69
71
|
import {
|
|
70
72
|
completeExtractJob,
|
|
71
73
|
countSuccessfulPages,
|
|
@@ -76,7 +78,7 @@ import {
|
|
|
76
78
|
saveExtractPages,
|
|
77
79
|
setExtractJobTotal,
|
|
78
80
|
settleExtractJob
|
|
79
|
-
} from "./chunk-
|
|
81
|
+
} from "./chunk-VQV7MB2S.js";
|
|
80
82
|
import {
|
|
81
83
|
BROWSER_OPEN_MIN_BALANCE_MC,
|
|
82
84
|
CONCURRENCY_PRICE_ID,
|
|
@@ -86,16 +88,20 @@ import {
|
|
|
86
88
|
MC_COSTS,
|
|
87
89
|
MC_PER_CREDIT,
|
|
88
90
|
MC_PER_USD,
|
|
91
|
+
MEDIA_TRANSCRIBE_HOLD_MC,
|
|
92
|
+
MEDIA_TRANSCRIBE_MARGIN_MULTIPLE,
|
|
89
93
|
MEMORY_AI_MARGIN_MULTIPLE,
|
|
90
94
|
MEMORY_PLANS,
|
|
91
95
|
MEMORY_PLAN_QUOTA,
|
|
92
96
|
SCHEDULING_PLANS,
|
|
97
|
+
SITE_AUDIT_LLM_MARGIN_MULTIPLE,
|
|
93
98
|
SUBSCRIPTION_TIERS,
|
|
94
99
|
SUBSCRIPTION_TIER_BY_KEY,
|
|
100
|
+
VIDEO_ANALYSIS_LLM_MARGIN_MULTIPLE,
|
|
95
101
|
browserActiveCostMc,
|
|
96
102
|
concurrencySlotBillingInfo,
|
|
97
103
|
insufficientBalanceResponse
|
|
98
|
-
} from "./chunk-
|
|
104
|
+
} from "./chunk-SIPXEFTR.js";
|
|
99
105
|
import {
|
|
100
106
|
CaptchaError,
|
|
101
107
|
RECAPTCHA_INSTRUCTIONS,
|
|
@@ -5339,6 +5345,7 @@ function extractJson(raw) {
|
|
|
5339
5345
|
var DeepInfraLlmClient = class {
|
|
5340
5346
|
apiKey;
|
|
5341
5347
|
model;
|
|
5348
|
+
totalCostUsd = 0;
|
|
5342
5349
|
constructor(apiKey, model = "Qwen/Qwen3.6-35B-A3B") {
|
|
5343
5350
|
this.apiKey = apiKey;
|
|
5344
5351
|
this.model = model;
|
|
@@ -5371,6 +5378,7 @@ ${raw}`
|
|
|
5371
5378
|
var OpenRouterLlmClient = class {
|
|
5372
5379
|
apiKey;
|
|
5373
5380
|
model;
|
|
5381
|
+
totalCostUsd = 0;
|
|
5374
5382
|
constructor(apiKey, model = "qwen/qwen3-235b-a22b") {
|
|
5375
5383
|
this.apiKey = apiKey;
|
|
5376
5384
|
this.model = model;
|
|
@@ -5383,6 +5391,9 @@ var OpenRouterLlmClient = class {
|
|
|
5383
5391
|
});
|
|
5384
5392
|
if (!resp.ok) throw new Error(`OpenRouter error ${resp.status}: ${await resp.text()}`);
|
|
5385
5393
|
const data = await resp.json();
|
|
5394
|
+
const cost = data.usage?.cost ?? 0;
|
|
5395
|
+
this.totalCostUsd += cost;
|
|
5396
|
+
void recordVendorUsage({ vendor: "openrouter", model: this.model, units: cost, unitType: "usd" });
|
|
5386
5397
|
return data.choices[0].message.content;
|
|
5387
5398
|
}
|
|
5388
5399
|
async completeJson(prompt) {
|
|
@@ -5408,6 +5419,9 @@ var FallbackLlmClient = class {
|
|
|
5408
5419
|
secondary;
|
|
5409
5420
|
lastServedBy = null;
|
|
5410
5421
|
lastPrimaryError = null;
|
|
5422
|
+
get totalCostUsd() {
|
|
5423
|
+
return this.primary.totalCostUsd + this.secondary.totalCostUsd;
|
|
5424
|
+
}
|
|
5411
5425
|
async complete(prompt) {
|
|
5412
5426
|
try {
|
|
5413
5427
|
const result = await this.primary.complete(prompt);
|
|
@@ -6894,8 +6908,23 @@ var SiteAuditService = class {
|
|
|
6894
6908
|
async getJobsForUser(userId) {
|
|
6895
6909
|
return this.deps.repo.listSiteAuditJobs(userId);
|
|
6896
6910
|
}
|
|
6911
|
+
async runLlmBilled(userId, source, fn) {
|
|
6912
|
+
const before = this.deps.llm.totalCostUsd;
|
|
6913
|
+
const result = await runWithCostContext({ ...currentCostContext(), op: "audit_site", userId }, fn);
|
|
6914
|
+
const deltaCostUsd = this.deps.llm.totalCostUsd - before;
|
|
6915
|
+
if (deltaCostUsd <= 0) return result;
|
|
6916
|
+
const mc = Math.round(deltaCostUsd * SITE_AUDIT_LLM_MARGIN_MULTIPLE * MC_PER_USD);
|
|
6917
|
+
if (mc <= 0) return result;
|
|
6918
|
+
const { ok } = await debitMc(userId, mc, LedgerOperation.SITE_AUDIT_LLM, source);
|
|
6919
|
+
if (!ok) {
|
|
6920
|
+
console.warn(`[site-audit-service] SITE_AUDIT_LLM surcharge debit failed for user ${userId} (${source}): insufficient balance for ${mc}mc`);
|
|
6921
|
+
}
|
|
6922
|
+
return result;
|
|
6923
|
+
}
|
|
6897
6924
|
async runIngestPhase(jobId, payload) {
|
|
6898
6925
|
const { request } = payload;
|
|
6926
|
+
const job = await this.deps.repo.getSiteAuditJob(jobId);
|
|
6927
|
+
if (!job) throw new Error(`[site-audit-service] runIngestPhase: job ${jobId} not found`);
|
|
6899
6928
|
const input = {
|
|
6900
6929
|
clientDomain: request.clientDomain,
|
|
6901
6930
|
sfInternalPath: request.sfInternalPath,
|
|
@@ -6910,14 +6939,18 @@ var SiteAuditService = class {
|
|
|
6910
6939
|
businessContext: request.businessContext,
|
|
6911
6940
|
priorAuditMemoryEnabled: request.priorAuditMemoryEnabled
|
|
6912
6941
|
};
|
|
6913
|
-
const result = await
|
|
6914
|
-
|
|
6915
|
-
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
|
|
6919
|
-
|
|
6920
|
-
|
|
6942
|
+
const result = await this.runLlmBilled(
|
|
6943
|
+
job.user_id,
|
|
6944
|
+
`${jobId}:phase1-ingest`,
|
|
6945
|
+
() => llmParseWithRetry(
|
|
6946
|
+
ValidatedFileManifestSchema,
|
|
6947
|
+
(attempt, error) => {
|
|
6948
|
+
void attempt;
|
|
6949
|
+
void error;
|
|
6950
|
+
return buildIngestValidatePrompt(input);
|
|
6951
|
+
},
|
|
6952
|
+
this.deps.llm
|
|
6953
|
+
)
|
|
6921
6954
|
);
|
|
6922
6955
|
await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase1-ingest", result);
|
|
6923
6956
|
}
|
|
@@ -6932,23 +6965,27 @@ var SiteAuditService = class {
|
|
|
6932
6965
|
const competitorDomainsCsvPath = request.competitorDomainsCsvPath;
|
|
6933
6966
|
void competitorDomainsCsvPath;
|
|
6934
6967
|
const competitors = await this._readCompetitorDomains(request);
|
|
6935
|
-
const results = await
|
|
6936
|
-
|
|
6937
|
-
|
|
6938
|
-
|
|
6939
|
-
|
|
6940
|
-
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
(
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
|
|
6950
|
-
|
|
6951
|
-
|
|
6968
|
+
const results = await this.runLlmBilled(
|
|
6969
|
+
job.user_id,
|
|
6970
|
+
`${jobId}:phase1-enrich`,
|
|
6971
|
+
() => Promise.all(
|
|
6972
|
+
competitors.map(
|
|
6973
|
+
(domain, idx) => limit(async () => {
|
|
6974
|
+
const competitorIndex = idx + 1;
|
|
6975
|
+
const rawPrompt = buildEnrichCompetitorPrompt({ domain, sessionPath });
|
|
6976
|
+
const prompt = rawPrompt.replace("<competitor-index>", String(competitorIndex));
|
|
6977
|
+
const result = await llmParseWithRetry(
|
|
6978
|
+
EnrichCompetitorOutputSchema,
|
|
6979
|
+
(attempt, error) => {
|
|
6980
|
+
void attempt;
|
|
6981
|
+
void error;
|
|
6982
|
+
return prompt;
|
|
6983
|
+
},
|
|
6984
|
+
this.deps.llm
|
|
6985
|
+
);
|
|
6986
|
+
return result;
|
|
6987
|
+
})
|
|
6988
|
+
)
|
|
6952
6989
|
)
|
|
6953
6990
|
);
|
|
6954
6991
|
await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase1-ingest", { competitors: results });
|
|
@@ -6962,19 +6999,23 @@ var SiteAuditService = class {
|
|
|
6962
6999
|
const clientDomain = request.clientDomain;
|
|
6963
7000
|
const urlInventory = [];
|
|
6964
7001
|
const directedEdgeCount = 0;
|
|
6965
|
-
const orphanAnnotation = await
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
|
|
6974
|
-
|
|
6975
|
-
|
|
6976
|
-
|
|
6977
|
-
|
|
7002
|
+
const orphanAnnotation = await this.runLlmBilled(
|
|
7003
|
+
job.user_id,
|
|
7004
|
+
`${jobId}:phase2-build-graph`,
|
|
7005
|
+
() => llmParseWithRetry(
|
|
7006
|
+
OrphanAnnotationOutputSchema,
|
|
7007
|
+
(attempt, error) => {
|
|
7008
|
+
void attempt;
|
|
7009
|
+
void error;
|
|
7010
|
+
return buildGraphPerSitePrompt({
|
|
7011
|
+
siteId: "client",
|
|
7012
|
+
domain: clientDomain,
|
|
7013
|
+
urlInventory,
|
|
7014
|
+
directedEdgeCount
|
|
7015
|
+
});
|
|
7016
|
+
},
|
|
7017
|
+
this.deps.llm
|
|
7018
|
+
)
|
|
6978
7019
|
);
|
|
6979
7020
|
const graphMetrics = await this.deps.python.runGraphMetrics({
|
|
6980
7021
|
directedLinkGraphPath: `${sessionPath}/phase1/directed_link_graph.json`,
|
|
@@ -7019,50 +7060,52 @@ var SiteAuditService = class {
|
|
|
7019
7060
|
}
|
|
7020
7061
|
if (currentBatch.length > 0) batches.push(currentBatch);
|
|
7021
7062
|
const usZipsCsvPath = process.env["US_ZIPS_CSV_PATH"] ?? "/Users/vilovieta/Downloads/sales-magician-api-leads-magician-01c6cff78e31/tools/analytics/data/uszips.csv";
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
|
|
7054
|
-
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
|
|
7062
|
-
|
|
7063
|
-
|
|
7063
|
+
await this.runLlmBilled(job.user_id, `${jobId}:phase3-classify`, async () => {
|
|
7064
|
+
for (const batch of batches) {
|
|
7065
|
+
let classified = [];
|
|
7066
|
+
let unclassifiedUrls = batch.map((u) => u.url);
|
|
7067
|
+
try {
|
|
7068
|
+
const pythonResult = await this.deps.python.runLocationClassifier({
|
|
7069
|
+
urls: batch.map((u) => u.url),
|
|
7070
|
+
usZipsCsvPath
|
|
7071
|
+
});
|
|
7072
|
+
classified = pythonResult.classified;
|
|
7073
|
+
unclassifiedUrls = pythonResult.unclassified;
|
|
7074
|
+
} catch (err) {
|
|
7075
|
+
console.warn(
|
|
7076
|
+
"[site-audit-service] runClassifyPhase: python.runLocationClassifier failed (falling back to LLM):",
|
|
7077
|
+
err instanceof Error ? err.message : String(err)
|
|
7078
|
+
);
|
|
7079
|
+
unclassifiedUrls = batch.map((u) => u.url);
|
|
7080
|
+
}
|
|
7081
|
+
for (const c of classified) {
|
|
7082
|
+
allClassifications.push({
|
|
7083
|
+
url: c.url,
|
|
7084
|
+
page_type: c.pageType,
|
|
7085
|
+
confidence: c.confidence > 0.6 ? "high" : c.confidence > 0.4 ? "medium" : "low",
|
|
7086
|
+
_classification_label: "[COMPUTED:python/location-classifier]",
|
|
7087
|
+
topic_cluster: null,
|
|
7088
|
+
location_signals: c.locationSignals
|
|
7089
|
+
});
|
|
7090
|
+
}
|
|
7091
|
+
if (unclassifiedUrls.length > 0) {
|
|
7092
|
+
const unclassifiedBatch = batch.filter((u) => unclassifiedUrls.includes(u.url));
|
|
7093
|
+
const llmResults = await llmParseWithRetry(
|
|
7094
|
+
z9.array(PageTypeClassificationRowSchema),
|
|
7095
|
+
(attempt, error) => {
|
|
7096
|
+
void attempt;
|
|
7097
|
+
void error;
|
|
7098
|
+
return buildMetricsClassifyPrompt({
|
|
7099
|
+
urls: unclassifiedBatch,
|
|
7100
|
+
siteDomain: clientDomain
|
|
7101
|
+
});
|
|
7102
|
+
},
|
|
7103
|
+
this.deps.llm
|
|
7104
|
+
);
|
|
7105
|
+
allClassifications.push(...llmResults);
|
|
7106
|
+
}
|
|
7064
7107
|
}
|
|
7065
|
-
}
|
|
7108
|
+
});
|
|
7066
7109
|
void sessionPath;
|
|
7067
7110
|
await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase3-classify", {
|
|
7068
7111
|
classifications: allClassifications,
|
|
@@ -7075,20 +7118,24 @@ var SiteAuditService = class {
|
|
|
7075
7118
|
if (!job) throw new Error(`[site-audit-service] runComparePhase: job ${jobId} not found`);
|
|
7076
7119
|
const request = JSON.parse(job.request);
|
|
7077
7120
|
const clientDomain = request.clientDomain;
|
|
7078
|
-
const result = await
|
|
7079
|
-
|
|
7080
|
-
|
|
7081
|
-
|
|
7082
|
-
|
|
7083
|
-
|
|
7084
|
-
|
|
7085
|
-
|
|
7086
|
-
|
|
7087
|
-
|
|
7088
|
-
|
|
7089
|
-
|
|
7090
|
-
|
|
7091
|
-
|
|
7121
|
+
const result = await this.runLlmBilled(
|
|
7122
|
+
job.user_id,
|
|
7123
|
+
`${jobId}:phase4-compare`,
|
|
7124
|
+
() => llmParseWithRetry(
|
|
7125
|
+
CompareRecommendOutputSchema,
|
|
7126
|
+
(attempt, error) => {
|
|
7127
|
+
void attempt;
|
|
7128
|
+
void error;
|
|
7129
|
+
return buildCompareRecommendPrompt({
|
|
7130
|
+
clientDomain,
|
|
7131
|
+
clientMoneyPages: [],
|
|
7132
|
+
competitorSites: [],
|
|
7133
|
+
clientUrlMetrics: [],
|
|
7134
|
+
clientPageTypes: []
|
|
7135
|
+
});
|
|
7136
|
+
},
|
|
7137
|
+
this.deps.llm
|
|
7138
|
+
)
|
|
7092
7139
|
);
|
|
7093
7140
|
await this.deps.repo.logSiteAuditPhaseComplete(jobId, "phase4-compare", result);
|
|
7094
7141
|
return result;
|
|
@@ -7118,20 +7165,24 @@ var SiteAuditService = class {
|
|
|
7118
7165
|
total: ilesTotal,
|
|
7119
7166
|
components: ilesComponents
|
|
7120
7167
|
};
|
|
7121
|
-
const reportMarkdown = await
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7168
|
+
const reportMarkdown = await this.runLlmBilled(
|
|
7169
|
+
job.user_id,
|
|
7170
|
+
`${jobId}:phase5-synthesize`,
|
|
7171
|
+
() => llmParseWithRetry(
|
|
7172
|
+
z9.string(),
|
|
7173
|
+
(attempt, error) => {
|
|
7174
|
+
void attempt;
|
|
7175
|
+
void error;
|
|
7176
|
+
return buildScoreSynthesizePrompt({
|
|
7177
|
+
preComputedArchitectureScore,
|
|
7178
|
+
preComputedIlesScore,
|
|
7179
|
+
rankedBacklog: [],
|
|
7180
|
+
clientDomain,
|
|
7181
|
+
sessionPath
|
|
7182
|
+
});
|
|
7183
|
+
},
|
|
7184
|
+
this.deps.llm
|
|
7185
|
+
)
|
|
7135
7186
|
);
|
|
7136
7187
|
const auditResult = SiteAuditResultSchema.parse({
|
|
7137
7188
|
jobId,
|
|
@@ -8140,6 +8191,11 @@ internalMemoryApp.post("/ai-debit", async (c) => {
|
|
|
8140
8191
|
}
|
|
8141
8192
|
const user = await getUserByEmail(body.identity.trim().toLowerCase());
|
|
8142
8193
|
if (!user) return c.json({ ok: false, reason: "no_user" }, 200);
|
|
8194
|
+
if (body.costUsd > 0) {
|
|
8195
|
+
await runWithCostContext({ ...currentCostContext(), op: "memory_ai", userId: user.id }, async () => {
|
|
8196
|
+
void recordVendorUsage({ vendor: "mcp_memory_ai", model: null, units: body.costUsd, unitType: "usd", error: null });
|
|
8197
|
+
});
|
|
8198
|
+
}
|
|
8143
8199
|
const mc = Math.round(body.costUsd * MEMORY_AI_MARGIN_MULTIPLE * MC_PER_USD);
|
|
8144
8200
|
if (mc <= 0) return c.json({ ok: true, mc: 0, balance_mc: null }, 200);
|
|
8145
8201
|
const { ok, balance_mc } = await debitMc(user.id, mc, LedgerOperation.MEMORY_AI, `${body.source ?? "ai"}:${body.op ?? ""}`);
|
|
@@ -8647,8 +8703,92 @@ async function ytHarvest(rawOptions) {
|
|
|
8647
8703
|
);
|
|
8648
8704
|
}
|
|
8649
8705
|
|
|
8650
|
-
// src/youtube/
|
|
8706
|
+
// src/youtube/audio-resolver.ts
|
|
8707
|
+
function mediaResolverUrl() {
|
|
8708
|
+
return process.env.FRAMES_SERVICE_URL?.trim() || void 0;
|
|
8709
|
+
}
|
|
8710
|
+
function mediaResolverSecret() {
|
|
8711
|
+
return process.env.FRAMES_SERVICE_SECRET?.trim() || void 0;
|
|
8712
|
+
}
|
|
8713
|
+
function mediaResolverConfigured() {
|
|
8714
|
+
return Boolean(mediaResolverUrl() && mediaResolverSecret());
|
|
8715
|
+
}
|
|
8716
|
+
var RESOLVE_TIMEOUT_MS = 24e4;
|
|
8717
|
+
async function resolveYouTubeAudio(videoId) {
|
|
8718
|
+
const base = mediaResolverUrl();
|
|
8719
|
+
const secret2 = mediaResolverSecret();
|
|
8720
|
+
if (!base || !secret2) return null;
|
|
8721
|
+
const controller = new AbortController();
|
|
8722
|
+
const timer = setTimeout(() => controller.abort(), RESOLVE_TIMEOUT_MS);
|
|
8723
|
+
try {
|
|
8724
|
+
const resp = await fetch(`${base.replace(/\/+$/, "")}/resolve-audio`, {
|
|
8725
|
+
method: "POST",
|
|
8726
|
+
headers: { "content-type": "application/json", "x-frames-secret": secret2 },
|
|
8727
|
+
body: JSON.stringify({ videoUrl: `https://www.youtube.com/watch?v=${videoId}` }),
|
|
8728
|
+
signal: controller.signal
|
|
8729
|
+
});
|
|
8730
|
+
if (!resp.ok) {
|
|
8731
|
+
const detail = await resp.text().catch(() => "");
|
|
8732
|
+
console.warn(`[audio-resolver] ${videoId}: http_${resp.status} ${detail.slice(0, 200)}`);
|
|
8733
|
+
return null;
|
|
8734
|
+
}
|
|
8735
|
+
const data = await resp.json();
|
|
8736
|
+
if (!data.mediaUrl) {
|
|
8737
|
+
console.warn(`[audio-resolver] ${videoId}: response missing mediaUrl`);
|
|
8738
|
+
return null;
|
|
8739
|
+
}
|
|
8740
|
+
return {
|
|
8741
|
+
mediaUrl: data.mediaUrl,
|
|
8742
|
+
durationS: Number(data.durationS) || 0,
|
|
8743
|
+
sizeBytes: Number(data.sizeBytes) || 0,
|
|
8744
|
+
mimeType: data.mimeType ?? "audio/mp4"
|
|
8745
|
+
};
|
|
8746
|
+
} catch (err) {
|
|
8747
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
8748
|
+
console.warn(`[audio-resolver] ${videoId}: resolve failed \u2014 ${msg}`);
|
|
8749
|
+
return null;
|
|
8750
|
+
} finally {
|
|
8751
|
+
clearTimeout(timer);
|
|
8752
|
+
}
|
|
8753
|
+
}
|
|
8754
|
+
|
|
8755
|
+
// src/services/media-transcription.ts
|
|
8651
8756
|
import { fal } from "@fal-ai/client";
|
|
8757
|
+
function transcriptMarkdown(title, text, chunks, durationMs) {
|
|
8758
|
+
const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
|
8759
|
+
const lines = [title, "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
|
|
8760
|
+
if (chunks.length) {
|
|
8761
|
+
lines.push("## Timestamped Segments", "");
|
|
8762
|
+
for (const ch of chunks) {
|
|
8763
|
+
lines.push(`**[${fmtTs2(ch.timestamp[0])} -> ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
|
|
8764
|
+
}
|
|
8765
|
+
}
|
|
8766
|
+
return lines.join("\n");
|
|
8767
|
+
}
|
|
8768
|
+
async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript") {
|
|
8769
|
+
const startMs = Date.now();
|
|
8770
|
+
const result = await fal.subscribe("fal-ai/wizper", {
|
|
8771
|
+
input: { audio_url: mediaUrl, task: "transcribe", language: "en" },
|
|
8772
|
+
logs: false,
|
|
8773
|
+
pollInterval: 3e3
|
|
8774
|
+
});
|
|
8775
|
+
const data = result.data;
|
|
8776
|
+
const text = data.text ?? "";
|
|
8777
|
+
const chunks = data.chunks ?? [];
|
|
8778
|
+
const durationMs = Date.now() - startMs;
|
|
8779
|
+
void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: durationMs / 1e3, unitType: "compute_sec" });
|
|
8780
|
+
const costUsd = vendorCostUsd("fal_wizper", durationMs / 1e3);
|
|
8781
|
+
return {
|
|
8782
|
+
text,
|
|
8783
|
+
chunks,
|
|
8784
|
+
durationMs,
|
|
8785
|
+
costUsd,
|
|
8786
|
+
markdown: transcriptMarkdown(markdownTitle, text, chunks, durationMs)
|
|
8787
|
+
};
|
|
8788
|
+
}
|
|
8789
|
+
|
|
8790
|
+
// src/youtube/CaptionFetcher.ts
|
|
8791
|
+
import { fal as fal2 } from "@fal-ai/client";
|
|
8652
8792
|
async function fetchViaYoutubeTranscript(videoId) {
|
|
8653
8793
|
try {
|
|
8654
8794
|
const { YoutubeTranscript } = await import("youtube-transcript");
|
|
@@ -8660,6 +8800,11 @@ async function fetchViaYoutubeTranscript(videoId) {
|
|
|
8660
8800
|
}));
|
|
8661
8801
|
const text = chunks.map((c) => c.text).join(" ");
|
|
8662
8802
|
const last = chunks[chunks.length - 1];
|
|
8803
|
+
const now = Date.now();
|
|
8804
|
+
await runWithCostContext(
|
|
8805
|
+
{ ...currentCostContext(), op: "yt_transcription", subOp: "youtube-transcript" },
|
|
8806
|
+
() => recordKernelSession({ kernelSessionId: null, source: "yt_caption_tier", headlessSent: null, openedAtMs: now, closedAtMs: now })
|
|
8807
|
+
);
|
|
8663
8808
|
return { videoId, text, chunks, durationMs: last ? last.timestamp[1] * 1e3 : 0, method: "youtube-transcript" };
|
|
8664
8809
|
} catch {
|
|
8665
8810
|
return null;
|
|
@@ -8711,166 +8856,246 @@ function parseTimedtextXml(xml) {
|
|
|
8711
8856
|
}
|
|
8712
8857
|
return results;
|
|
8713
8858
|
}
|
|
8714
|
-
|
|
8715
|
-
|
|
8716
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
|
|
8726
|
-
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8859
|
+
var INNERTUBE_CLIENTS = [
|
|
8860
|
+
{ name: "ANDROID", version: "20.10.38", userAgent: "com.google.android.youtube/20.10.38 (Linux; U; Android 14)" },
|
|
8861
|
+
{ name: "IOS", version: "20.10.4", userAgent: "com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)" },
|
|
8862
|
+
{ name: "ANDROID_VR", version: "1.62.27", userAgent: "com.google.android.apps.youtube.vr.oculus/1.62.27 (Linux; U; Android 12L) gzip" }
|
|
8863
|
+
];
|
|
8864
|
+
function isBotBlockStatus(status) {
|
|
8865
|
+
return status === "LOGIN_REQUIRED" || status === "AGE_VERIFICATION_REQUIRED";
|
|
8866
|
+
}
|
|
8867
|
+
async function probePlayer(page, videoId) {
|
|
8868
|
+
return page.evaluate(
|
|
8869
|
+
async ({ vid, clients }) => {
|
|
8870
|
+
let lastStatus = null;
|
|
8871
|
+
for (const client2 of clients) {
|
|
8872
|
+
const resp = await fetch("/youtubei/v1/player?prettyPrint=false", {
|
|
8873
|
+
method: "POST",
|
|
8874
|
+
headers: { "Content-Type": "application/json", "User-Agent": client2.userAgent },
|
|
8875
|
+
body: JSON.stringify({
|
|
8876
|
+
context: { client: { clientName: client2.name, clientVersion: client2.version } },
|
|
8877
|
+
videoId: vid
|
|
8878
|
+
})
|
|
8879
|
+
}).catch(() => null);
|
|
8880
|
+
if (!resp?.ok) {
|
|
8881
|
+
lastStatus = resp ? `player_http_${resp.status}` : "player_fetch_failed";
|
|
8882
|
+
continue;
|
|
8883
|
+
}
|
|
8884
|
+
const data = await resp.json();
|
|
8885
|
+
lastStatus = data?.playabilityStatus?.status ?? null;
|
|
8886
|
+
const audioFormats = (data?.streamingData?.adaptiveFormats ?? []).filter((f) => typeof f.url === "string" && f.url && typeof f.mimeType === "string" && f.mimeType.startsWith("audio/")).sort((a, b) => (Number(a.contentLength) || Infinity) - (Number(b.contentLength) || Infinity));
|
|
8887
|
+
const pick = audioFormats[0] ?? null;
|
|
8888
|
+
let captions = null;
|
|
8889
|
+
const tracks = data?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
|
|
8890
|
+
if (tracks.length > 0) {
|
|
8891
|
+
const track = tracks.find((t) => t.languageCode === "en") ?? tracks[0];
|
|
8892
|
+
const sep = track.baseUrl.includes("?") ? "&" : "?";
|
|
8893
|
+
const xmlResp = await fetch(`${track.baseUrl}${sep}fmt=json3`).catch(() => null);
|
|
8894
|
+
if (xmlResp?.ok) captions = await xmlResp.text();
|
|
8895
|
+
}
|
|
8896
|
+
if (!captions && !pick) continue;
|
|
8897
|
+
return {
|
|
8898
|
+
captions,
|
|
8899
|
+
status: lastStatus,
|
|
8900
|
+
audio: pick ? {
|
|
8901
|
+
url: pick.url,
|
|
8902
|
+
mimeType: pick.mimeType,
|
|
8903
|
+
bitrate: pick.bitrate ?? null,
|
|
8904
|
+
contentLength: pick.contentLength ? Number(pick.contentLength) || null : null,
|
|
8905
|
+
approxDurationMs: pick.approxDurationMs ? Number(pick.approxDurationMs) || null : null
|
|
8906
|
+
} : null
|
|
8907
|
+
};
|
|
8908
|
+
}
|
|
8909
|
+
return { captions: null, audio: null, status: lastStatus };
|
|
8910
|
+
},
|
|
8911
|
+
{ vid: videoId, clients: INNERTUBE_CLIENTS }
|
|
8912
|
+
).catch(() => null);
|
|
8913
|
+
}
|
|
8914
|
+
var MAX_AUDIO_BYTES = 96 * 1024 * 1024;
|
|
8915
|
+
async function downloadWholeAudioStreamInOneRequestBecauseYouTubeAllowsOnlyOnePerIp(page, audio) {
|
|
8916
|
+
if (audio.contentLength && audio.contentLength > MAX_AUDIO_BYTES) {
|
|
8917
|
+
console.warn(`[CaptionFetcher] audio stream ${audio.contentLength} bytes exceeds ${MAX_AUDIO_BYTES} byte cap`);
|
|
8918
|
+
return null;
|
|
8919
|
+
}
|
|
8920
|
+
const sep = audio.url.includes("?") ? "&" : "?";
|
|
8921
|
+
const url = audio.contentLength ? `${audio.url}${sep}range=0-${audio.contentLength - 1}` : audio.url;
|
|
8922
|
+
const result = await page.evaluate(async (u) => {
|
|
8923
|
+
try {
|
|
8924
|
+
const resp = await fetch(u);
|
|
8925
|
+
if (!resp.ok) return { ok: false, error: `http_${resp.status}` };
|
|
8926
|
+
const blob = await resp.blob();
|
|
8927
|
+
const dataUrl = await new Promise((resolve, reject) => {
|
|
8928
|
+
const reader = new FileReader();
|
|
8929
|
+
reader.onload = () => resolve(reader.result);
|
|
8930
|
+
reader.onerror = () => reject(reader.error);
|
|
8931
|
+
reader.readAsDataURL(blob);
|
|
8740
8932
|
});
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
|
|
8747
|
-
|
|
8748
|
-
return xmlResp.ok ? xmlResp.text() : null;
|
|
8749
|
-
}, videoId);
|
|
8750
|
-
if (!xml) return null;
|
|
8751
|
-
let entries = parseTimedtextJson3(xml);
|
|
8752
|
-
if (entries.length === 0) entries = parseTimedtextXml(xml);
|
|
8753
|
-
if (entries.length === 0) return null;
|
|
8754
|
-
const chunks = entries.map((e) => ({
|
|
8755
|
-
timestamp: [e.start / 1e3, (e.start + e.dur) / 1e3],
|
|
8756
|
-
text: e.text
|
|
8757
|
-
}));
|
|
8758
|
-
const text = chunks.map((c) => c.text).join(" ");
|
|
8759
|
-
const last = chunks[chunks.length - 1];
|
|
8760
|
-
return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-innertube" };
|
|
8761
|
-
} catch {
|
|
8933
|
+
return { ok: true, base64: dataUrl.slice(dataUrl.indexOf(",") + 1) };
|
|
8934
|
+
} catch (e) {
|
|
8935
|
+
return { ok: false, error: String(e) };
|
|
8936
|
+
}
|
|
8937
|
+
}, url).catch((e) => ({ ok: false, error: String(e) }));
|
|
8938
|
+
if (!result.ok || !result.base64) {
|
|
8939
|
+
console.warn(`[CaptionFetcher] audio fetch failed (${result.error ?? "unknown"}) for a ${audio.contentLength ?? 0} byte stream`);
|
|
8762
8940
|
return null;
|
|
8763
|
-
} finally {
|
|
8764
|
-
await driver.close();
|
|
8765
8941
|
}
|
|
8942
|
+
const bytes = Buffer.from(result.base64, "base64");
|
|
8943
|
+
if (audio.contentLength && bytes.length < audio.contentLength) {
|
|
8944
|
+
console.warn(`[CaptionFetcher] partial audio: ${bytes.length} of ${audio.contentLength} bytes`);
|
|
8945
|
+
return null;
|
|
8946
|
+
}
|
|
8947
|
+
return { bytes, mimeType: audio.mimeType.split(";")[0].trim() };
|
|
8766
8948
|
}
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
return new Promise((resolve) => {
|
|
8771
|
-
const video = document.querySelector("video");
|
|
8772
|
-
if (!video) {
|
|
8773
|
-
resolve({ ok: false, error: "no video element" });
|
|
8774
|
-
return;
|
|
8775
|
-
}
|
|
8776
|
-
video.muted = false;
|
|
8777
|
-
video.volume = 1;
|
|
8778
|
-
try {
|
|
8779
|
-
const ctx = new AudioContext();
|
|
8780
|
-
const src = ctx.createMediaElementSource(video);
|
|
8781
|
-
const dest = ctx.createMediaStreamDestination();
|
|
8782
|
-
src.connect(dest);
|
|
8783
|
-
src.connect(ctx.destination);
|
|
8784
|
-
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "audio/ogg";
|
|
8785
|
-
const recorder = new MediaRecorder(dest.stream, { mimeType, audioBitsPerSecond: 48e3 });
|
|
8786
|
-
const chunks = [];
|
|
8787
|
-
recorder.ondataavailable = (e) => {
|
|
8788
|
-
if (e.data.size > 0) chunks.push(e.data);
|
|
8789
|
-
};
|
|
8790
|
-
recorder.onstop = async () => {
|
|
8791
|
-
const blob = new Blob(chunks, { type: mimeType });
|
|
8792
|
-
const ab = await blob.arrayBuffer();
|
|
8793
|
-
const arr = new Uint8Array(ab);
|
|
8794
|
-
let binary = "";
|
|
8795
|
-
const chunkSize = 8192;
|
|
8796
|
-
for (let i = 0; i < arr.length; i += chunkSize) {
|
|
8797
|
-
binary += String.fromCharCode(...arr.slice(i, Math.min(i + chunkSize, arr.length)));
|
|
8798
|
-
}
|
|
8799
|
-
resolve({ ok: true, base64: btoa(binary), size: ab.byteLength, mimeType });
|
|
8800
|
-
};
|
|
8801
|
-
recorder.start(1e3);
|
|
8802
|
-
video.playbackRate = 2;
|
|
8803
|
-
video.play().catch(() => void 0);
|
|
8804
|
-
setTimeout(() => {
|
|
8805
|
-
recorder.stop();
|
|
8806
|
-
ctx.close();
|
|
8807
|
-
}, recordSecs * 1e3);
|
|
8808
|
-
} catch (e) {
|
|
8809
|
-
resolve({ ok: false, error: String(e) });
|
|
8810
|
-
}
|
|
8811
|
-
});
|
|
8812
|
-
}, WHISPER_RECORD_SECONDS);
|
|
8813
|
-
if (!result.ok || !result.base64 || !result.mimeType) return null;
|
|
8814
|
-
return { bytes: Buffer.from(result.base64, "base64"), mimeType: result.mimeType };
|
|
8815
|
-
}
|
|
8816
|
-
async function attemptKernelWhisper(videoId, kernelApiKey, kernelProxyId, falKey, start) {
|
|
8817
|
-
const driver = new BrowserDriver();
|
|
8949
|
+
async function transcribeWithWizper(videoId, audio, startMs) {
|
|
8950
|
+
const falKey = process.env.FAL_KEY;
|
|
8951
|
+
if (!falKey) return null;
|
|
8818
8952
|
try {
|
|
8819
|
-
|
|
8820
|
-
const
|
|
8821
|
-
|
|
8822
|
-
await
|
|
8823
|
-
|
|
8824
|
-
|
|
8825
|
-
|
|
8826
|
-
const playStatus = await page.evaluate(() => {
|
|
8827
|
-
const w = window;
|
|
8828
|
-
return w.ytInitialPlayerResponse?.playabilityStatus?.status;
|
|
8829
|
-
});
|
|
8830
|
-
if (playStatus !== "OK") {
|
|
8831
|
-
await driver.close();
|
|
8832
|
-
return null;
|
|
8833
|
-
}
|
|
8834
|
-
const audioInfo = await captureAudioViaMediaRecorder(page);
|
|
8835
|
-
await driver.close();
|
|
8836
|
-
if (!audioInfo || audioInfo.bytes.length < 1e4) return null;
|
|
8837
|
-
fal.config({ credentials: falKey });
|
|
8838
|
-
const ext = audioInfo.mimeType.includes("ogg") ? "ogg" : "webm";
|
|
8839
|
-
const audioFile = new File([new Uint8Array(audioInfo.bytes)], `audio.${ext}`, { type: audioInfo.mimeType });
|
|
8840
|
-
const uploadedUrl = await fal.storage.upload(audioFile);
|
|
8841
|
-
const result = await fal.subscribe("fal-ai/wizper", {
|
|
8842
|
-
input: { audio_url: uploadedUrl, task: "transcribe", language: "en" },
|
|
8953
|
+
fal2.config({ credentials: falKey });
|
|
8954
|
+
const ext = audio.mimeType.includes("webm") ? "webm" : audio.mimeType.includes("mp4") ? "m4a" : "audio";
|
|
8955
|
+
const audioFile = new File([new Uint8Array(audio.bytes)], `audio.${ext}`, { type: audio.mimeType });
|
|
8956
|
+
const uploadedUrl = await fal2.storage.upload(audioFile);
|
|
8957
|
+
const subscribeStartMs = Date.now();
|
|
8958
|
+
const result = await fal2.subscribe("fal-ai/wizper", {
|
|
8959
|
+
input: { audio_url: uploadedUrl, task: "transcribe", language: "en", chunk_level: "segment" },
|
|
8843
8960
|
logs: false,
|
|
8844
8961
|
pollInterval: 3e3
|
|
8845
8962
|
});
|
|
8963
|
+
const subscribeDurationMs = Date.now() - subscribeStartMs;
|
|
8846
8964
|
const data = result.data;
|
|
8847
8965
|
const chunks = (data.chunks ?? []).map((c) => ({
|
|
8848
8966
|
timestamp: c.timestamp,
|
|
8849
8967
|
text: c.text.trim()
|
|
8850
8968
|
}));
|
|
8851
|
-
|
|
8852
|
-
void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: audioSec / 60, unitType: "audio_min" });
|
|
8969
|
+
void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: subscribeDurationMs / 1e3, unitType: "compute_sec" });
|
|
8853
8970
|
const text = data.text ?? chunks.map((c) => c.text).join(" ");
|
|
8854
8971
|
if (!text) return null;
|
|
8855
|
-
return { videoId, text, chunks, durationMs: Date.now() -
|
|
8856
|
-
} catch {
|
|
8857
|
-
|
|
8972
|
+
return { videoId, text, chunks, durationMs: Date.now() - startMs, method: "browser-whisper" };
|
|
8973
|
+
} catch (err) {
|
|
8974
|
+
console.warn(`[CaptionFetcher] wizper transcription failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
8858
8975
|
return null;
|
|
8859
8976
|
}
|
|
8860
8977
|
}
|
|
8861
|
-
|
|
8978
|
+
var YT_RESIDENTIAL_LOCATION = process.env.YOUTUBE_PROXY_LOCATION ?? "Austin, Texas";
|
|
8979
|
+
async function resolveResidentialProxy(kernelApiKey) {
|
|
8980
|
+
try {
|
|
8981
|
+
const resolved = await resolveKernelProxyId({
|
|
8982
|
+
kernelApiKey,
|
|
8983
|
+
proxyMode: "location",
|
|
8984
|
+
location: YT_RESIDENTIAL_LOCATION,
|
|
8985
|
+
gl: "us",
|
|
8986
|
+
fresh: true
|
|
8987
|
+
});
|
|
8988
|
+
return { kernelProxyId: resolved.kernelProxyId, disposableProxyId: resolved.disposableProxyId };
|
|
8989
|
+
} catch (err) {
|
|
8990
|
+
console.warn(`[CaptionFetcher] residential proxy resolution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
8991
|
+
return {};
|
|
8992
|
+
}
|
|
8993
|
+
}
|
|
8994
|
+
async function probeAndExtract(videoId, opts) {
|
|
8862
8995
|
const kernelApiKey = browserServiceApiKey();
|
|
8863
|
-
|
|
8864
|
-
const
|
|
8865
|
-
|
|
8996
|
+
if (!kernelApiKey) return null;
|
|
8997
|
+
const kernelProxyId = opts?.kernelProxyId ?? browserServiceProxyId();
|
|
8998
|
+
const driver = new BrowserDriver();
|
|
8999
|
+
return runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: "browser-innertube" }, async () => {
|
|
9000
|
+
let entries = [];
|
|
9001
|
+
let audio = null;
|
|
9002
|
+
let retryWithFreshIp = false;
|
|
9003
|
+
try {
|
|
9004
|
+
await driver.launch({ kernelApiKey, kernelProxyId, headless: true, headlessMode: "headless", viewport: { width: 1280, height: 800 }, locale: "en-US" });
|
|
9005
|
+
const page = driver.getPage();
|
|
9006
|
+
await driver.navigateTo(`https://www.youtube.com/watch?v=${videoId}`);
|
|
9007
|
+
await page.waitForFunction(
|
|
9008
|
+
() => !!window.ytcfg,
|
|
9009
|
+
{ timeout: 2e4 }
|
|
9010
|
+
);
|
|
9011
|
+
const probe = await probePlayer(page, videoId);
|
|
9012
|
+
if (!probe) return null;
|
|
9013
|
+
if (probe.captions && !opts?.skipCaptions) {
|
|
9014
|
+
entries = parseTimedtextJson3(probe.captions);
|
|
9015
|
+
if (entries.length === 0) entries = parseTimedtextXml(probe.captions);
|
|
9016
|
+
}
|
|
9017
|
+
if (entries.length === 0) {
|
|
9018
|
+
if (!probe.audio) {
|
|
9019
|
+
retryWithFreshIp = isBotBlockStatus(probe.status);
|
|
9020
|
+
console.warn(`[CaptionFetcher] no captions and no audio stream for ${videoId} (playability=${probe.status ?? "unknown"}, proxied=${Boolean(kernelProxyId)})`);
|
|
9021
|
+
return { entries, audio: null, retryWithFreshIp };
|
|
9022
|
+
}
|
|
9023
|
+
audio = await downloadWholeAudioStreamInOneRequestBecauseYouTubeAllowsOnlyOnePerIp(page, probe.audio);
|
|
9024
|
+
if (!audio) return { entries, audio: null, retryWithFreshIp: true };
|
|
9025
|
+
}
|
|
9026
|
+
} catch (err) {
|
|
9027
|
+
console.warn(`[CaptionFetcher] kernel session failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
9028
|
+
return null;
|
|
9029
|
+
} finally {
|
|
9030
|
+
const servedSubOp = audio ? "browser-whisper" : "browser-innertube";
|
|
9031
|
+
await runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: servedSubOp }, () => driver.close());
|
|
9032
|
+
}
|
|
9033
|
+
return { entries, audio, retryWithFreshIp };
|
|
9034
|
+
});
|
|
9035
|
+
}
|
|
9036
|
+
async function extractWithProxyEscalation(videoId, opts) {
|
|
9037
|
+
const direct = await probeAndExtract(videoId, opts);
|
|
9038
|
+
if (direct && (direct.entries.length > 0 || direct.audio)) return direct;
|
|
9039
|
+
const shouldEscalate = direct === null || direct.retryWithFreshIp;
|
|
9040
|
+
if (!shouldEscalate) return direct;
|
|
9041
|
+
const kernelApiKey = browserServiceApiKey();
|
|
9042
|
+
if (!kernelApiKey) return direct;
|
|
9043
|
+
const { kernelProxyId, disposableProxyId } = await resolveResidentialProxy(kernelApiKey);
|
|
9044
|
+
if (!kernelProxyId) return direct;
|
|
9045
|
+
try {
|
|
9046
|
+
console.warn(`[CaptionFetcher] ${videoId}: retrying through residential proxy after bot block`);
|
|
9047
|
+
const viaProxy = await probeAndExtract(videoId, { ...opts, kernelProxyId });
|
|
9048
|
+
return viaProxy ?? direct;
|
|
9049
|
+
} finally {
|
|
9050
|
+
if (disposableProxyId) {
|
|
9051
|
+
await deleteKernelProxyId(kernelApiKey, disposableProxyId).catch(
|
|
9052
|
+
(err) => console.warn(`[CaptionFetcher] failed to delete disposable proxy: ${err instanceof Error ? err.message : String(err)}`)
|
|
9053
|
+
);
|
|
9054
|
+
}
|
|
9055
|
+
}
|
|
9056
|
+
}
|
|
9057
|
+
async function fetchViaKernelSession(videoId) {
|
|
8866
9058
|
const start = Date.now();
|
|
8867
|
-
|
|
8868
|
-
|
|
8869
|
-
|
|
8870
|
-
|
|
9059
|
+
const outcome = await extractWithProxyEscalation(videoId);
|
|
9060
|
+
if (!outcome) return null;
|
|
9061
|
+
if (outcome.entries.length > 0) {
|
|
9062
|
+
const chunks = outcome.entries.map((e) => ({
|
|
9063
|
+
timestamp: [e.start / 1e3, (e.start + e.dur) / 1e3],
|
|
9064
|
+
text: e.text
|
|
9065
|
+
}));
|
|
9066
|
+
const text = chunks.map((c) => c.text).join(" ");
|
|
9067
|
+
return { videoId, text, chunks, durationMs: Date.now() - start, method: "browser-innertube" };
|
|
9068
|
+
}
|
|
9069
|
+
if (outcome.audio) {
|
|
9070
|
+
return runWithCostContext(
|
|
9071
|
+
{ ...currentCostContext(), op: "yt_transcription", subOp: "browser-whisper" },
|
|
9072
|
+
() => transcribeWithWizper(videoId, outcome.audio, start)
|
|
9073
|
+
);
|
|
8871
9074
|
}
|
|
8872
9075
|
return null;
|
|
8873
9076
|
}
|
|
9077
|
+
async function fetchViaResolvedAudio(videoId) {
|
|
9078
|
+
if (!mediaResolverConfigured()) return null;
|
|
9079
|
+
return runWithCostContext({ ...currentCostContext(), op: "yt_transcription", subOp: "audio-whisper" }, async () => {
|
|
9080
|
+
const resolved = await resolveYouTubeAudio(videoId);
|
|
9081
|
+
if (!resolved) return null;
|
|
9082
|
+
try {
|
|
9083
|
+
const transcript = await transcribeMediaUrl(resolved.mediaUrl, `# Transcript \u2014 ${videoId}`);
|
|
9084
|
+
if (!transcript.text) return null;
|
|
9085
|
+
const chunks = transcript.chunks.map((c) => ({ timestamp: c.timestamp, text: c.text.trim() }));
|
|
9086
|
+
return {
|
|
9087
|
+
videoId,
|
|
9088
|
+
text: transcript.text,
|
|
9089
|
+
chunks,
|
|
9090
|
+
durationMs: resolved.durationS > 0 ? Math.round(resolved.durationS * 1e3) : transcript.durationMs,
|
|
9091
|
+
method: "audio-whisper"
|
|
9092
|
+
};
|
|
9093
|
+
} catch (err) {
|
|
9094
|
+
console.warn(`[CaptionFetcher] transcription of resolved audio failed for ${videoId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
9095
|
+
return null;
|
|
9096
|
+
}
|
|
9097
|
+
});
|
|
9098
|
+
}
|
|
8874
9099
|
function finalizeCaptions(result) {
|
|
8875
9100
|
const chunks = result.chunks.map((c) => {
|
|
8876
9101
|
const startOk = Number.isFinite(c.timestamp[0]);
|
|
@@ -8889,11 +9114,13 @@ function finalizeCaptions(result) {
|
|
|
8889
9114
|
async function fetchCaptions(videoId) {
|
|
8890
9115
|
const primary = await fetchViaYoutubeTranscript(videoId);
|
|
8891
9116
|
if (primary) return finalizeCaptions(primary);
|
|
8892
|
-
const
|
|
8893
|
-
if (
|
|
8894
|
-
const
|
|
8895
|
-
if (
|
|
8896
|
-
throw new Error(
|
|
9117
|
+
const result = await fetchViaKernelSession(videoId);
|
|
9118
|
+
if (result) return finalizeCaptions(result);
|
|
9119
|
+
const viaResolvedAudio = await fetchViaResolvedAudio(videoId);
|
|
9120
|
+
if (viaResolvedAudio) return finalizeCaptions(viaResolvedAudio);
|
|
9121
|
+
throw new Error(
|
|
9122
|
+
`Could not transcribe ${videoId}: it has no caption track and its audio could not be retrieved.`
|
|
9123
|
+
);
|
|
8897
9124
|
}
|
|
8898
9125
|
|
|
8899
9126
|
// src/api/api-auth.ts
|
|
@@ -9221,7 +9448,7 @@ youtubeApp.post("/transcribe", createApiKeyAuth(), async (c) => {
|
|
|
9221
9448
|
const { ok: trOk, balance_mc: trBal } = await debitMc(user.id, holdMc, LedgerOperation.TRANSCRIPTION_HOLD, id);
|
|
9222
9449
|
if (!trOk) return c.json(insufficientBalanceResponse(trBal, holdMc), 402);
|
|
9223
9450
|
debited = true;
|
|
9224
|
-
const result = await fetchCaptions(id);
|
|
9451
|
+
const result = await runWithCostContext({ ...currentCostContext(), op: "yt_transcription", userId: user.id }, () => fetchCaptions(id));
|
|
9225
9452
|
const lastChunk = result.chunks?.[result.chunks.length - 1];
|
|
9226
9453
|
const chunkSecs = lastChunk && Number.isFinite(lastChunk.timestamp[1]) ? lastChunk.timestamp[1] : 0;
|
|
9227
9454
|
const durationSecs = Number.isFinite(result.durationMs) ? result.durationMs / 1e3 : 0;
|
|
@@ -10259,42 +10486,6 @@ async function extractFacebookOrganicVideoFromPage(page, sourceUrl, quality = "b
|
|
|
10259
10486
|
|
|
10260
10487
|
// src/api/facebook-ad-routes.ts
|
|
10261
10488
|
import { fal as fal3 } from "@fal-ai/client";
|
|
10262
|
-
|
|
10263
|
-
// src/services/media-transcription.ts
|
|
10264
|
-
import { fal as fal2 } from "@fal-ai/client";
|
|
10265
|
-
function transcriptMarkdown(title, text, chunks, durationMs) {
|
|
10266
|
-
const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
|
10267
|
-
const lines = [title, "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
|
|
10268
|
-
if (chunks.length) {
|
|
10269
|
-
lines.push("## Timestamped Segments", "");
|
|
10270
|
-
for (const ch of chunks) {
|
|
10271
|
-
lines.push(`**[${fmtTs2(ch.timestamp[0])} -> ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
|
|
10272
|
-
}
|
|
10273
|
-
}
|
|
10274
|
-
return lines.join("\n");
|
|
10275
|
-
}
|
|
10276
|
-
async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript") {
|
|
10277
|
-
const startMs = Date.now();
|
|
10278
|
-
const result = await fal2.subscribe("fal-ai/wizper", {
|
|
10279
|
-
input: { audio_url: mediaUrl, task: "transcribe", language: "en" },
|
|
10280
|
-
logs: false,
|
|
10281
|
-
pollInterval: 3e3
|
|
10282
|
-
});
|
|
10283
|
-
const data = result.data;
|
|
10284
|
-
const text = data.text ?? "";
|
|
10285
|
-
const chunks = data.chunks ?? [];
|
|
10286
|
-
const durationMs = Date.now() - startMs;
|
|
10287
|
-
const audioSec = chunks.length ? chunks[chunks.length - 1]?.timestamp?.[1] ?? 0 : 0;
|
|
10288
|
-
void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: audioSec / 60, unitType: "audio_min" });
|
|
10289
|
-
return {
|
|
10290
|
-
text,
|
|
10291
|
-
chunks,
|
|
10292
|
-
durationMs,
|
|
10293
|
-
markdown: transcriptMarkdown(markdownTitle, text, chunks, durationMs)
|
|
10294
|
-
};
|
|
10295
|
-
}
|
|
10296
|
-
|
|
10297
|
-
// src/api/facebook-ad-routes.ts
|
|
10298
10489
|
var FacebookAdBodySchema = z13.object({
|
|
10299
10490
|
url: z13.string().trim().optional(),
|
|
10300
10491
|
libraryId: z13.string().trim().optional(),
|
|
@@ -10475,16 +10666,16 @@ facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
|
|
|
10475
10666
|
});
|
|
10476
10667
|
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
10477
10668
|
fal3.config({ credentials: process.env.FAL_KEY });
|
|
10478
|
-
const holdMc =
|
|
10669
|
+
const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
|
|
10479
10670
|
let debited = false;
|
|
10480
10671
|
try {
|
|
10481
10672
|
const { ok, balance_mc } = await debitMc(fbUser.id, holdMc, LedgerOperation.FB_TRANSCRIBE, videoUrl);
|
|
10482
10673
|
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
|
|
10483
10674
|
debited = true;
|
|
10484
10675
|
const transcript = await transcribeFacebookVideoUrl(videoUrl, "# Facebook Ad Transcript");
|
|
10485
|
-
const
|
|
10486
|
-
const diff = holdMc -
|
|
10487
|
-
if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "
|
|
10676
|
+
const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
|
|
10677
|
+
const diff = holdMc - settleMc;
|
|
10678
|
+
if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "real-cost reconcile");
|
|
10488
10679
|
else if (diff < 0) await debitMc(fbUser.id, -diff, LedgerOperation.FB_TRANSCRIBE, videoUrl);
|
|
10489
10680
|
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 } });
|
|
10490
10681
|
return c.json(transcript);
|
|
@@ -10520,20 +10711,22 @@ facebookAdApp.post("/video-transcribe", createApiKeyAuth(), async (c) => {
|
|
|
10520
10711
|
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
10521
10712
|
fal3.config({ credentials: process.env.FAL_KEY });
|
|
10522
10713
|
const driver = new BrowserDriver();
|
|
10523
|
-
const holdMc =
|
|
10714
|
+
const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
|
|
10524
10715
|
let debited = false;
|
|
10525
10716
|
try {
|
|
10526
10717
|
const { ok, balance_mc } = await debitMc(fbUser.id, holdMc, LedgerOperation.FB_TRANSCRIBE, sourceUrl.href);
|
|
10527
10718
|
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
|
|
10528
10719
|
debited = true;
|
|
10720
|
+
const kernelStartMs = Date.now();
|
|
10529
10721
|
await driver.launch(await kernelLaunchOptsResidential());
|
|
10530
10722
|
await driver.navigateTo(sourceUrl.href);
|
|
10531
10723
|
const page = driver.getPage();
|
|
10532
10724
|
const video = await extractFacebookOrganicVideoFromPage(page, sourceUrl.href, body.quality);
|
|
10725
|
+
const kernelMs = Date.now() - kernelStartMs;
|
|
10533
10726
|
const transcript = await transcribeFacebookVideoUrl(video.videoUrl, "# Facebook Organic Video Transcript");
|
|
10534
|
-
const
|
|
10535
|
-
const diff = holdMc -
|
|
10536
|
-
if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "
|
|
10727
|
+
const settleMc = Math.round((transcript.costUsd + kernelCostUsd(kernelMs, true)) * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
|
|
10728
|
+
const diff = holdMc - settleMc;
|
|
10729
|
+
if (diff > 0) await creditMc(fbUser.id, diff, LedgerOperation.FB_TRANSCRIBE_REFUND, "real-cost reconcile");
|
|
10537
10730
|
else if (diff < 0) await debitMc(fbUser.id, -diff, LedgerOperation.FB_TRANSCRIBE, sourceUrl.href);
|
|
10538
10731
|
const result = {
|
|
10539
10732
|
...video,
|
|
@@ -10925,9 +11118,11 @@ googleAdsApp.post("/search", createApiKeyAuth(), async (c) => {
|
|
|
10925
11118
|
const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.google_ads_search, LedgerOperation.GOOGLE_ADS_SEARCH, body.query.trim());
|
|
10926
11119
|
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.google_ads_search), 402);
|
|
10927
11120
|
debited = true;
|
|
10928
|
-
await
|
|
10929
|
-
|
|
10930
|
-
|
|
11121
|
+
const result = await runWithCostContext({ ...currentCostContext(), op: "google_ads_search", userId: user.id }, async () => {
|
|
11122
|
+
await driver.launch(await kernelLaunchOptsResidential2());
|
|
11123
|
+
const extractor = new GoogleAdsExtractor(driver);
|
|
11124
|
+
return extractor.search(body.query.trim(), region, maxResults);
|
|
11125
|
+
});
|
|
10931
11126
|
if (result.advertisers.length === 0) {
|
|
10932
11127
|
await creditMc(user.id, MC_COSTS.google_ads_search, LedgerOperation.GOOGLE_ADS_SEARCH_REFUND, "empty result");
|
|
10933
11128
|
refunded = true;
|
|
@@ -10963,9 +11158,11 @@ googleAdsApp.post("/page-intel", createApiKeyAuth(), async (c) => {
|
|
|
10963
11158
|
const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.google_ads_intel, LedgerOperation.GOOGLE_ADS_INTEL, queryLabel);
|
|
10964
11159
|
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.google_ads_intel), 402);
|
|
10965
11160
|
debited = true;
|
|
10966
|
-
await
|
|
10967
|
-
|
|
10968
|
-
|
|
11161
|
+
const result = await runWithCostContext({ ...currentCostContext(), op: "google_ads_intel", userId: user.id }, async () => {
|
|
11162
|
+
await driver.launch(await kernelLaunchOptsResidential2());
|
|
11163
|
+
const extractor = new GoogleAdsExtractor(driver);
|
|
11164
|
+
return extractor.pageIntel({ advertiserId: body.advertiserId, domain: body.domain, region, maxAds });
|
|
11165
|
+
});
|
|
10969
11166
|
if (result.ads.length === 0) {
|
|
10970
11167
|
await creditMc(user.id, MC_COSTS.google_ads_intel, LedgerOperation.GOOGLE_ADS_INTEL_REFUND, "empty result");
|
|
10971
11168
|
refunded = true;
|
|
@@ -10996,16 +11193,16 @@ googleAdsApp.post("/transcribe", createApiKeyAuth(), async (c) => {
|
|
|
10996
11193
|
const gate = await acquireConcurrencyGate(user, "google_ads_transcribe", { reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"), metadata: { videoUrl } });
|
|
10997
11194
|
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
10998
11195
|
fal4.config({ credentials: process.env.FAL_KEY });
|
|
10999
|
-
const holdMc =
|
|
11196
|
+
const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
|
|
11000
11197
|
let debited = false;
|
|
11001
11198
|
try {
|
|
11002
11199
|
const { ok, balance_mc } = await debitMc(user.id, holdMc, LedgerOperation.GOOGLE_ADS_TRANSCRIBE, videoUrl);
|
|
11003
11200
|
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, holdMc), 402);
|
|
11004
11201
|
debited = true;
|
|
11005
|
-
const transcript = await transcribeMediaUrl(videoUrl, "# Google Ad Transcript");
|
|
11006
|
-
const
|
|
11007
|
-
const diff = holdMc -
|
|
11008
|
-
if (diff > 0) await creditMc(user.id, diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE_REFUND, "
|
|
11202
|
+
const transcript = await runWithCostContext({ ...currentCostContext(), op: "google_ads_transcribe", userId: user.id }, () => transcribeMediaUrl(videoUrl, "# Google Ad Transcript"));
|
|
11203
|
+
const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
|
|
11204
|
+
const diff = holdMc - settleMc;
|
|
11205
|
+
if (diff > 0) await creditMc(user.id, diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE_REFUND, "real-cost reconcile");
|
|
11009
11206
|
else if (diff < 0) await debitMc(user.id, -diff, LedgerOperation.GOOGLE_ADS_TRANSCRIBE, videoUrl);
|
|
11010
11207
|
await logRequestEvent({ userId: user.id, source: "google_ads_transcribe", status: "done", query: videoUrl, resultCount: transcript.chunks.length, result: { text: transcript.text, chunks: transcript.chunks, durationMs: transcript.durationMs } });
|
|
11011
11208
|
return c.json(transcript);
|
|
@@ -11654,10 +11851,15 @@ instagramApp.post("/media-download", createApiKeyAuth(), async (c) => {
|
|
|
11654
11851
|
if (!audioUrl) {
|
|
11655
11852
|
warnings.push("Transcript requested, but no audio/video track was available.");
|
|
11656
11853
|
} else {
|
|
11657
|
-
const
|
|
11658
|
-
|
|
11854
|
+
const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
|
|
11855
|
+
const tx = await debitMc(user.id, holdMc, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
|
|
11856
|
+
if (!tx.ok) return c.json(insufficientBalanceResponse(tx.balance_mc, holdMc), 402);
|
|
11659
11857
|
transcriptDebited = true;
|
|
11660
11858
|
transcript = await transcribeMediaUrl(audioUrl, "# Instagram Media Transcript");
|
|
11859
|
+
const settleMc = Math.round(transcript.costUsd * MEDIA_TRANSCRIBE_MARGIN_MULTIPLE * MC_PER_USD);
|
|
11860
|
+
const diff = holdMc - settleMc;
|
|
11861
|
+
if (diff > 0) await creditMc(user.id, diff, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "real-cost reconcile");
|
|
11862
|
+
else if (diff < 0) await debitMc(user.id, -diff, LedgerOperation.INSTAGRAM_TRANSCRIBE, sourceUrl.href);
|
|
11661
11863
|
}
|
|
11662
11864
|
}
|
|
11663
11865
|
const result = {
|
|
@@ -11672,7 +11874,7 @@ instagramApp.post("/media-download", createApiKeyAuth(), async (c) => {
|
|
|
11672
11874
|
return c.json(result);
|
|
11673
11875
|
} catch (err) {
|
|
11674
11876
|
const msg = err instanceof Error ? err.message : String(err);
|
|
11675
|
-
if (transcriptDebited) await creditMc(user.id,
|
|
11877
|
+
if (transcriptDebited) await creditMc(user.id, MEDIA_TRANSCRIBE_HOLD_MC, LedgerOperation.INSTAGRAM_TRANSCRIBE_REFUND, "failed call");
|
|
11676
11878
|
if (mediaDebited) await creditMc(user.id, MC_COSTS.instagram_media, LedgerOperation.INSTAGRAM_MEDIA_REFUND, "failed call");
|
|
11677
11879
|
await logRequestEvent({ userId: user.id, source: "instagram_media_download", status: "failed", query: sourceUrl.href, error: msg });
|
|
11678
11880
|
if (msg.toLowerCase().includes("blocked") || msg.toLowerCase().includes("captcha")) return c.json({ error: msg }, 503);
|
|
@@ -11760,6 +11962,7 @@ redditApp.post("/thread", createApiKeyAuth(), async (c) => {
|
|
|
11760
11962
|
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
11761
11963
|
let debited = false;
|
|
11762
11964
|
let refunded = false;
|
|
11965
|
+
let commentDebitMc = 0;
|
|
11763
11966
|
try {
|
|
11764
11967
|
const { ok, balance_mc } = await debitMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD, body.url);
|
|
11765
11968
|
if (!ok) return c.json(insufficientBalanceResponse(balance_mc, MC_COSTS.reddit_thread), 402);
|
|
@@ -11799,10 +12002,23 @@ redditApp.post("/thread", createApiKeyAuth(), async (c) => {
|
|
|
11799
12002
|
result.comments = result.comments.slice(0, body.maxComments);
|
|
11800
12003
|
result.numComments = result.comments.length;
|
|
11801
12004
|
}
|
|
12005
|
+
const commentCount = Array.isArray(result.comments) ? result.comments.length : 0;
|
|
12006
|
+
commentDebitMc = commentCount * MC_COSTS.reddit_comment;
|
|
12007
|
+
if (commentDebitMc > 0) {
|
|
12008
|
+
const commentDebit = await debitMc(user.id, commentDebitMc, LedgerOperation.REDDIT_COMMENT, body.url);
|
|
12009
|
+
if (!commentDebit.ok) {
|
|
12010
|
+
const affordable = Math.floor((commentDebit.balance_mc ?? 0) / MC_COSTS.reddit_comment);
|
|
12011
|
+
result.comments = result.comments.slice(0, affordable);
|
|
12012
|
+
result.numComments = affordable;
|
|
12013
|
+
commentDebitMc = affordable * MC_COSTS.reddit_comment;
|
|
12014
|
+
if (commentDebitMc > 0) await debitMc(user.id, commentDebitMc, LedgerOperation.REDDIT_COMMENT, body.url);
|
|
12015
|
+
}
|
|
12016
|
+
}
|
|
11802
12017
|
await logRequestEvent({ userId: user.id, source: "reddit_thread", status: "done", query: body.url, resultCount: Number(result.numComments ?? 0), result });
|
|
11803
12018
|
return c.json(result);
|
|
11804
12019
|
} catch (err) {
|
|
11805
12020
|
if (debited && !refunded) await creditMc(user.id, MC_COSTS.reddit_thread, LedgerOperation.REDDIT_THREAD_REFUND, "failed call");
|
|
12021
|
+
if (commentDebitMc > 0) await creditMc(user.id, commentDebitMc, LedgerOperation.REDDIT_COMMENT_REFUND, "failed call");
|
|
11806
12022
|
const msg = err instanceof Error ? err.message : String(err);
|
|
11807
12023
|
await logRequestEvent({ userId: user.id, source: "reddit_thread", status: "failed", query: body.url, error: msg });
|
|
11808
12024
|
return c.json({ error: msg }, 500);
|
|
@@ -12077,6 +12293,21 @@ videoApp.post("/status", createApiKeyAuth(), async (c) => {
|
|
|
12077
12293
|
effectiveFrames: st.frameCount ?? null
|
|
12078
12294
|
};
|
|
12079
12295
|
}
|
|
12296
|
+
if ((st.status === "done" || st.status === "failed") && typeof st.costUsd === "number" && st.costUsd > 0) {
|
|
12297
|
+
const llmOp = `${LedgerOperation.VIDEO_ANALYSIS_LLM}:${runId}`;
|
|
12298
|
+
if (!await ledgerExistsForOperation(user.id, llmOp)) {
|
|
12299
|
+
await runWithCostContext({ ...currentCostContext(), op: "video_analysis", userId: user.id }, async () => {
|
|
12300
|
+
void recordVendorUsage({ vendor: "mcp_memory_video", model: null, units: st.costUsd, unitType: "usd", error: null });
|
|
12301
|
+
});
|
|
12302
|
+
const surchargeMc = Math.round(st.costUsd * VIDEO_ANALYSIS_LLM_MARGIN_MULTIPLE * MC_PER_USD);
|
|
12303
|
+
if (surchargeMc > 0) {
|
|
12304
|
+
const { ok } = await debitMc(user.id, surchargeMc, llmOp, `real-cost-surcharge:${runId}`);
|
|
12305
|
+
if (!ok) {
|
|
12306
|
+
console.warn(`[video-routes] VIDEO_ANALYSIS_LLM surcharge debit failed for user ${user.id} (run ${runId}): insufficient balance for ${surchargeMc}mc`);
|
|
12307
|
+
}
|
|
12308
|
+
}
|
|
12309
|
+
}
|
|
12310
|
+
}
|
|
12080
12311
|
return c.json({
|
|
12081
12312
|
ok: true,
|
|
12082
12313
|
runId,
|
|
@@ -13247,7 +13478,7 @@ trustpilotApp.post("/reviews", createApiKeyAuth(), async (c) => {
|
|
|
13247
13478
|
let pagesFetched = 0;
|
|
13248
13479
|
try {
|
|
13249
13480
|
await driver.launch({
|
|
13250
|
-
headless:
|
|
13481
|
+
headless: false,
|
|
13251
13482
|
kernelApiKey: browserServiceApiKey(),
|
|
13252
13483
|
viewport: { width: 1280, height: 900 },
|
|
13253
13484
|
locale: "en-US"
|
|
@@ -13440,7 +13671,7 @@ g2App.post("/reviews", createApiKeyAuth(), async (c) => {
|
|
|
13440
13671
|
const driver = new BrowserDriver();
|
|
13441
13672
|
try {
|
|
13442
13673
|
await driver.launch({
|
|
13443
|
-
headless:
|
|
13674
|
+
headless: false,
|
|
13444
13675
|
kernelApiKey: browserServiceApiKey(),
|
|
13445
13676
|
kernelProxyId: proxyId,
|
|
13446
13677
|
viewport: { width: 1280, height: 900 },
|
|
@@ -20278,10 +20509,18 @@ function opForCostPath(p) {
|
|
|
20278
20509
|
return "fb_ad";
|
|
20279
20510
|
}
|
|
20280
20511
|
if (p.startsWith("/instagram/")) return "instagram";
|
|
20512
|
+
if (p.startsWith("/google-ads/")) {
|
|
20513
|
+
if (p.endsWith("/transcribe")) return "google_ads_transcribe";
|
|
20514
|
+
if (p.endsWith("/search")) return "google_ads_search";
|
|
20515
|
+
return "google_ads_intel";
|
|
20516
|
+
}
|
|
20281
20517
|
if (p === "/reddit/thread") return "reddit_thread";
|
|
20518
|
+
if (p.startsWith("/trustpilot/")) return "trustpilot_reviews";
|
|
20519
|
+
if (p.startsWith("/g2/")) return "g2_reviews";
|
|
20282
20520
|
if (p.startsWith("/api/internal/site-architecture-auditor")) return "audit_site";
|
|
20283
20521
|
if (p.startsWith("/api/internal/memory")) return "memory_ai";
|
|
20284
20522
|
if (p.startsWith("/agent/")) return "browser_session";
|
|
20523
|
+
if (p.startsWith("/video/")) return "video_analysis";
|
|
20285
20524
|
return null;
|
|
20286
20525
|
}
|
|
20287
20526
|
app.use("*", (c, next) => {
|
|
@@ -20993,16 +21232,20 @@ app.post("/harvest/sync", auth2, async (c) => {
|
|
|
20993
21232
|
AbortSignal.timeout(syncTimeoutMs)
|
|
20994
21233
|
]);
|
|
20995
21234
|
try {
|
|
20996
|
-
const
|
|
20997
|
-
|
|
20998
|
-
|
|
20999
|
-
|
|
21000
|
-
|
|
21001
|
-
|
|
21002
|
-
|
|
21003
|
-
|
|
21004
|
-
|
|
21005
|
-
|
|
21235
|
+
const harvestCtx = currentCostContext();
|
|
21236
|
+
const result = await runWithCostContext(
|
|
21237
|
+
{ ...harvestCtx, op: options.serpOnly ? "serp" : "paa", userId: user.id },
|
|
21238
|
+
() => harvest({
|
|
21239
|
+
...options,
|
|
21240
|
+
kernelApiKey: browserServiceApiKey(),
|
|
21241
|
+
headless: true,
|
|
21242
|
+
format: "json",
|
|
21243
|
+
outputDir: "/tmp/paa-output-api",
|
|
21244
|
+
signal: syncSignal,
|
|
21245
|
+
softDeadlineMs: Date.now() + Math.floor(syncTimeoutMs * 0.8),
|
|
21246
|
+
onAttemptEvent: recordAttempt
|
|
21247
|
+
})
|
|
21248
|
+
);
|
|
21006
21249
|
await completeJob(jobId, result);
|
|
21007
21250
|
const attempts = await listHarvestAttempts(jobId, user.id);
|
|
21008
21251
|
if (!options.serpOnly) {
|
|
@@ -21681,7 +21924,7 @@ app.get("/cron/tick", async (c) => {
|
|
|
21681
21924
|
if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
|
|
21682
21925
|
return c.json({ error: "Unauthorized" }, 401);
|
|
21683
21926
|
}
|
|
21684
|
-
const { drainQueue } = await import("./worker-
|
|
21927
|
+
const { drainQueue } = await import("./worker-WCSKUAOT.js");
|
|
21685
21928
|
const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
|
|
21686
21929
|
const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
|
|
21687
21930
|
const [results, sweepResult, reapResult, expiredResult, blobCleanup] = await Promise.all([
|
|
@@ -21699,7 +21942,7 @@ app.post("/api/internal/extract-refinalize/:id", async (c) => {
|
|
|
21699
21942
|
return c.json({ error: "Unauthorized" }, 401);
|
|
21700
21943
|
}
|
|
21701
21944
|
const jobId = c.req.param("id");
|
|
21702
|
-
const { getExtractJob: getExtractJob2, completeExtractJob: completeExtractJob2 } = await import("./site-extract-repository-
|
|
21945
|
+
const { getExtractJob: getExtractJob2, completeExtractJob: completeExtractJob2 } = await import("./site-extract-repository-6X2C7RLF.js");
|
|
21703
21946
|
const { assembleExtractArtifacts } = await import("./extract-bundle-COS56ZDO.js");
|
|
21704
21947
|
const job = await getExtractJob2(jobId);
|
|
21705
21948
|
if (!job) return c.json({ error: "job not found" }, 404);
|
|
@@ -21707,7 +21950,7 @@ app.post("/api/internal/extract-refinalize/:id", async (c) => {
|
|
|
21707
21950
|
await completeExtractJob2(jobId, stored);
|
|
21708
21951
|
let settlement = "already_settled_or_refunded";
|
|
21709
21952
|
if (job.billedMc == null && job.userId != null) {
|
|
21710
|
-
const { settleExtractJob: settleExtractJob2, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-
|
|
21953
|
+
const { settleExtractJob: settleExtractJob2, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-6X2C7RLF.js");
|
|
21711
21954
|
const heldMc = Number(job.options.heldMc ?? 0);
|
|
21712
21955
|
const successful = await countSuccessfulPages2(jobId);
|
|
21713
21956
|
const usedMc = Math.min(successful * MC_COSTS.page_scrape, heldMc);
|
|
@@ -21871,4 +22114,4 @@ app.get("/blog/:slug/", (c) => {
|
|
|
21871
22114
|
export {
|
|
21872
22115
|
app
|
|
21873
22116
|
};
|
|
21874
|
-
//# sourceMappingURL=server-
|
|
22117
|
+
//# sourceMappingURL=server-HNZG36IP.js.map
|