open-agents-ai 0.187.572 → 0.187.573
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/index.js +174 -27
- package/dist/scripts/web_scrape.py +228 -63
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3800,7 +3800,7 @@ var init_web_fetch = __esm({
|
|
|
3800
3800
|
WebFetchTool = class {
|
|
3801
3801
|
name = "web_fetch";
|
|
3802
3802
|
_fetchCache = /* @__PURE__ */ new Map();
|
|
3803
|
-
description = "Fetch a single web page and return its text content (HTML stripped to plain text). FASTEST web tool — use this for reading any single URL: documentation, articles, README files, API references, Stack Overflow answers. Limitations: no JavaScript rendering (SPAs/React apps return empty), no link following, no cookies/auth, no structured data extraction. If the page is blank or incomplete, switch to web_crawl with strategy='playwright'. For scraping/extracting structured data (prices, listings, tables), use web_crawl instead. For search engine queries, use web_search instead. For interactive browser sessions (login, form filling, clicking), use browser_action instead.";
|
|
3803
|
+
description = "Fetch a single web page and return its text content (HTML stripped to plain text). FASTEST web tool — use this for reading any single URL: documentation, articles, README files, API references, Stack Overflow answers. Limitations: no JavaScript rendering (SPAs/React apps return empty), no link following, no cookies/auth, no structured data extraction. On timeout, automatically falls back to browser_action (headless Chrome) for slow/heavy pages. If the page is blank or incomplete, switch to web_crawl with strategy='playwright'. For scraping/extracting structured data (prices, listings, tables), use web_crawl instead. For search engine queries, use web_search instead. For interactive browser sessions (login, form filling, clicking), use browser_action instead.";
|
|
3804
3804
|
parameters = {
|
|
3805
3805
|
type: "object",
|
|
3806
3806
|
properties: {
|
|
@@ -3869,12 +3869,105 @@ var init_web_fetch = __esm({
|
|
|
3869
3869
|
durationMs: performance.now() - start2
|
|
3870
3870
|
};
|
|
3871
3871
|
} catch (error) {
|
|
3872
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3873
|
+
if (/abort|timeout/i.test(errMsg)) {
|
|
3874
|
+
const fallback = await this.#hydraFallback(url, maxLength, start2);
|
|
3875
|
+
if (fallback)
|
|
3876
|
+
return fallback;
|
|
3877
|
+
}
|
|
3872
3878
|
return {
|
|
3873
3879
|
success: false,
|
|
3874
3880
|
output: "",
|
|
3875
|
-
error:
|
|
3881
|
+
error: errMsg,
|
|
3882
|
+
durationMs: performance.now() - start2
|
|
3883
|
+
};
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
/** Fallback: use Hydra Chrome automation service (web-scrape-service on :8130)
|
|
3887
|
+
* when the HTTP fetch times out. Navigates with a real headless browser
|
|
3888
|
+
* so slow/heavy/JS pages render fully. Returns null if the service is
|
|
3889
|
+
* unavailable or any step fails — the original timeout error propagates. */
|
|
3890
|
+
async #hydraFallback(url, maxLength, start2) {
|
|
3891
|
+
const BASE = "http://localhost:8130";
|
|
3892
|
+
try {
|
|
3893
|
+
const health = await fetch(`${BASE}/health`, {
|
|
3894
|
+
signal: AbortSignal.timeout(2e3)
|
|
3895
|
+
});
|
|
3896
|
+
if (!health.ok)
|
|
3897
|
+
return null;
|
|
3898
|
+
} catch {
|
|
3899
|
+
return null;
|
|
3900
|
+
}
|
|
3901
|
+
try {
|
|
3902
|
+
const sessionRes = await fetch(`${BASE}/session/start`, {
|
|
3903
|
+
method: "POST",
|
|
3904
|
+
headers: { "Content-Type": "application/json" },
|
|
3905
|
+
body: JSON.stringify({ headless: true }),
|
|
3906
|
+
signal: AbortSignal.timeout(1e4)
|
|
3907
|
+
});
|
|
3908
|
+
const sessionData = await sessionRes.json();
|
|
3909
|
+
if (!sessionData.ok || !sessionData.session_id)
|
|
3910
|
+
return null;
|
|
3911
|
+
const sid = sessionData.session_id;
|
|
3912
|
+
const navRes = await fetch(`${BASE}/navigate`, {
|
|
3913
|
+
method: "POST",
|
|
3914
|
+
headers: { "Content-Type": "application/json" },
|
|
3915
|
+
body: JSON.stringify({ sid, url }),
|
|
3916
|
+
signal: AbortSignal.timeout(6e4)
|
|
3917
|
+
});
|
|
3918
|
+
const navData = await navRes.json();
|
|
3919
|
+
if (!navData.ok) {
|
|
3920
|
+
fetch(`${BASE}/session/close`, {
|
|
3921
|
+
method: "POST",
|
|
3922
|
+
headers: { "Content-Type": "application/json" },
|
|
3923
|
+
body: JSON.stringify({ sid })
|
|
3924
|
+
}).catch(() => {
|
|
3925
|
+
});
|
|
3926
|
+
return null;
|
|
3927
|
+
}
|
|
3928
|
+
const domRes = await fetch(`${BASE}/dom?sid=${encodeURIComponent(sid)}`, {
|
|
3929
|
+
signal: AbortSignal.timeout(3e4)
|
|
3930
|
+
});
|
|
3931
|
+
const domData = await domRes.json();
|
|
3932
|
+
if (!domData.ok) {
|
|
3933
|
+
fetch(`${BASE}/session/close`, {
|
|
3934
|
+
method: "POST",
|
|
3935
|
+
headers: { "Content-Type": "application/json" },
|
|
3936
|
+
body: JSON.stringify({ sid })
|
|
3937
|
+
}).catch(() => {
|
|
3938
|
+
});
|
|
3939
|
+
return null;
|
|
3940
|
+
}
|
|
3941
|
+
const dom = domData.dom;
|
|
3942
|
+
if (!dom || dom.length < 50) {
|
|
3943
|
+
fetch(`${BASE}/session/close`, {
|
|
3944
|
+
method: "POST",
|
|
3945
|
+
headers: { "Content-Type": "application/json" },
|
|
3946
|
+
body: JSON.stringify({ sid })
|
|
3947
|
+
}).catch(() => {
|
|
3948
|
+
});
|
|
3949
|
+
return null;
|
|
3950
|
+
}
|
|
3951
|
+
fetch(`${BASE}/session/close`, {
|
|
3952
|
+
method: "POST",
|
|
3953
|
+
headers: { "Content-Type": "application/json" },
|
|
3954
|
+
body: JSON.stringify({ sid })
|
|
3955
|
+
}).catch(() => {
|
|
3956
|
+
});
|
|
3957
|
+
const text = this.#stripHtml(dom);
|
|
3958
|
+
this._fetchCache.set(url, { text, fetchedAt: Date.now() });
|
|
3959
|
+
const truncated = text.length > maxLength;
|
|
3960
|
+
return {
|
|
3961
|
+
success: true,
|
|
3962
|
+
output: `[Hydra fallback: HTTP fetch timed out, retrieved via Chrome browser]
|
|
3963
|
+
|
|
3964
|
+
` + (truncated ? `${text.slice(0, maxLength)}
|
|
3965
|
+
|
|
3966
|
+
[Content truncated to ${maxLength} characters]` : text),
|
|
3876
3967
|
durationMs: performance.now() - start2
|
|
3877
3968
|
};
|
|
3969
|
+
} catch {
|
|
3970
|
+
return null;
|
|
3878
3971
|
}
|
|
3879
3972
|
}
|
|
3880
3973
|
#stripHtml(html) {
|
|
@@ -16769,19 +16862,36 @@ function isYouTubeUrl(url) {
|
|
|
16769
16862
|
return /(?:youtube\.com\/(?:watch|shorts|live|embed|v\/)|youtu\.be\/)/i.test(url);
|
|
16770
16863
|
}
|
|
16771
16864
|
function ensureYtDlp() {
|
|
16772
|
-
|
|
16773
|
-
|
|
16774
|
-
|
|
16775
|
-
|
|
16865
|
+
if (_ytDlpPath)
|
|
16866
|
+
return _ytDlpPath;
|
|
16867
|
+
const isWin2 = process.platform === "win32";
|
|
16868
|
+
const venvDir = join27(homedir8(), ".open-agents", "venv");
|
|
16869
|
+
const pipPath = isWin2 ? join27(venvDir, "Scripts", "pip.exe") : join27(venvDir, "bin", "pip");
|
|
16870
|
+
const ytDlpPath = isWin2 ? join27(venvDir, "Scripts", "yt-dlp.exe") : join27(venvDir, "bin", "yt-dlp");
|
|
16871
|
+
if (!existsSync22(pipPath)) {
|
|
16776
16872
|
try {
|
|
16777
|
-
|
|
16778
|
-
|
|
16873
|
+
mkdirSync9(join27(homedir8(), ".open-agents"), { recursive: true });
|
|
16874
|
+
execSync13(`python3 -m venv "${venvDir}"`, {
|
|
16875
|
+
timeout: 3e4,
|
|
16779
16876
|
stdio: "pipe"
|
|
16780
16877
|
});
|
|
16781
|
-
return true;
|
|
16782
16878
|
} catch {
|
|
16783
|
-
return
|
|
16879
|
+
return null;
|
|
16880
|
+
}
|
|
16881
|
+
}
|
|
16882
|
+
try {
|
|
16883
|
+
execSync13(`"${pipPath}" install -U yt-dlp 2>&1`, {
|
|
16884
|
+
timeout: 6e4,
|
|
16885
|
+
stdio: "pipe"
|
|
16886
|
+
});
|
|
16887
|
+
_ytDlpPath = ytDlpPath;
|
|
16888
|
+
return ytDlpPath;
|
|
16889
|
+
} catch {
|
|
16890
|
+
if (existsSync22(ytDlpPath)) {
|
|
16891
|
+
_ytDlpPath = ytDlpPath;
|
|
16892
|
+
return ytDlpPath;
|
|
16784
16893
|
}
|
|
16894
|
+
return null;
|
|
16785
16895
|
}
|
|
16786
16896
|
}
|
|
16787
16897
|
function formatTime(seconds) {
|
|
@@ -16789,7 +16899,7 @@ function formatTime(seconds) {
|
|
|
16789
16899
|
const s2 = Math.floor(seconds % 60);
|
|
16790
16900
|
return `${String(m2).padStart(2, "0")}:${String(s2).padStart(2, "0")}`;
|
|
16791
16901
|
}
|
|
16792
|
-
var AUDIO_EXTS, VIDEO_EXTS, _tcModule, _tcChecked, TranscribeFileTool, TranscribeUrlTool, YouTubeDownloadTool;
|
|
16902
|
+
var AUDIO_EXTS, VIDEO_EXTS, _tcModule, _tcChecked, TranscribeFileTool, _ytDlpPath, TranscribeUrlTool, YouTubeDownloadTool;
|
|
16793
16903
|
var init_transcribe_tool = __esm({
|
|
16794
16904
|
"packages/execution/dist/tools/transcribe-tool.js"() {
|
|
16795
16905
|
"use strict";
|
|
@@ -16986,9 +17096,10 @@ var init_transcribe_tool = __esm({
|
|
|
16986
17096
|
}
|
|
16987
17097
|
}
|
|
16988
17098
|
};
|
|
17099
|
+
_ytDlpPath = null;
|
|
16989
17100
|
TranscribeUrlTool = class {
|
|
16990
17101
|
name = "transcribe_url";
|
|
16991
|
-
description = "Download and transcribe audio/video from a URL. Supports YouTube links (youtube.com/watch?v=..., youtu.be/...) and direct media URLs (MP3, WAV, MP4, etc.). YouTube audio is extracted via yt-dlp (
|
|
17102
|
+
description = "Download and transcribe audio/video from a URL. Supports YouTube links (youtube.com/watch?v=..., youtu.be/...) and direct media URLs (MP3, WAV, MP4, etc.). YouTube audio is extracted via yt-dlp (shared venv at ~/.open-agents/venv/). If yt-dlp gets YouTube 403 errors, the tool auto-upgrades it. Transcription is local via faster-whisper (no cloud API).";
|
|
16992
17103
|
parameters = {
|
|
16993
17104
|
type: "object",
|
|
16994
17105
|
properties: {
|
|
@@ -17026,17 +17137,18 @@ var init_transcribe_tool = __esm({
|
|
|
17026
17137
|
let tmpFile = "";
|
|
17027
17138
|
try {
|
|
17028
17139
|
if (isYouTubeUrl(url)) {
|
|
17029
|
-
|
|
17140
|
+
const ytDlp = ensureYtDlp();
|
|
17141
|
+
if (!ytDlp) {
|
|
17030
17142
|
return {
|
|
17031
17143
|
success: false,
|
|
17032
17144
|
output: "",
|
|
17033
|
-
error: "yt-dlp not
|
|
17145
|
+
error: "yt-dlp not available via shared venv. Run: python3 -m venv ~/.open-agents/venv && ~/.open-agents/venv/bin/pip install yt-dlp",
|
|
17034
17146
|
durationMs: performance.now() - start2
|
|
17035
17147
|
};
|
|
17036
17148
|
}
|
|
17037
17149
|
tmpFile = `${tmpBase}.mp3`;
|
|
17038
17150
|
try {
|
|
17039
|
-
execSync13(`
|
|
17151
|
+
execSync13(`"${ytDlp}" -x --audio-format mp3 --audio-quality 5 -o "${tmpBase}.%(ext)s" "${url}" 2>&1`, { timeout: 3e5, stdio: ["pipe", "pipe", "pipe"] });
|
|
17040
17152
|
if (!existsSync22(tmpFile)) {
|
|
17041
17153
|
const { readdirSync: rd } = __require("node:fs");
|
|
17042
17154
|
const files = rd(tmpDir).filter((f2) => f2.startsWith(`download-`) && f2 !== ".gitkeep");
|
|
@@ -17046,10 +17158,11 @@ var init_transcribe_tool = __esm({
|
|
|
17046
17158
|
}
|
|
17047
17159
|
} catch (dlErr) {
|
|
17048
17160
|
const errMsg = dlErr instanceof Error ? dlErr.message : String(dlErr);
|
|
17161
|
+
const upgradeHint = errMsg.includes("403") ? " YouTube 403 error — yt-dlp was auto-upgraded. Retry; if the issue persists, the video may be region-restricted." : " Is the video available and not age-restricted?";
|
|
17049
17162
|
return {
|
|
17050
17163
|
success: false,
|
|
17051
17164
|
output: "",
|
|
17052
|
-
error: `yt-dlp failed: ${errMsg.slice(0, 200)}
|
|
17165
|
+
error: `yt-dlp failed: ${errMsg.slice(0, 200)}.${upgradeHint}`,
|
|
17053
17166
|
durationMs: performance.now() - start2
|
|
17054
17167
|
};
|
|
17055
17168
|
}
|
|
@@ -17109,7 +17222,7 @@ ${result.output}`,
|
|
|
17109
17222
|
};
|
|
17110
17223
|
YouTubeDownloadTool = class {
|
|
17111
17224
|
name = "youtube_download";
|
|
17112
|
-
description = "Download video or audio from YouTube. Saves mp4 (video) or
|
|
17225
|
+
description = "Download video or audio from YouTube. Saves mp4 (video), mp3, or wav (audio) to the working directory. Uses yt-dlp (auto-upgraded to fix YouTube 403 errors) and ffmpeg internally for audio conversion. If you get YouTube 403 errors, the tool auto-upgrades yt-dlp. For ffmpeg-based processing (cutting, segmenting, concatenating), download wav format which is raw PCM suitable for shell ffmpeg pipelines. Supports youtube.com/watch, youtu.be, shorts, live URLs.";
|
|
17113
17226
|
parameters = {
|
|
17114
17227
|
type: "object",
|
|
17115
17228
|
properties: {
|
|
@@ -17119,8 +17232,8 @@ ${result.output}`,
|
|
|
17119
17232
|
},
|
|
17120
17233
|
format: {
|
|
17121
17234
|
type: "string",
|
|
17122
|
-
enum: ["mp3", "mp4"],
|
|
17123
|
-
description: "Output format: 'mp3'
|
|
17235
|
+
enum: ["mp3", "mp4", "wav"],
|
|
17236
|
+
description: "Output format: 'mp3' (compressed audio), 'wav' (raw PCM — use for ffmpeg segmentation), 'mp4' (video). Default: mp3"
|
|
17124
17237
|
},
|
|
17125
17238
|
output_dir: {
|
|
17126
17239
|
type: "string",
|
|
@@ -17139,25 +17252,44 @@ ${result.output}`,
|
|
|
17139
17252
|
const format3 = String(args.format ?? "mp3").toLowerCase();
|
|
17140
17253
|
const outputDir = String(args.output_dir ?? this.workingDir);
|
|
17141
17254
|
if (!url) {
|
|
17142
|
-
return {
|
|
17255
|
+
return {
|
|
17256
|
+
success: false,
|
|
17257
|
+
output: "",
|
|
17258
|
+
error: "URL is required",
|
|
17259
|
+
durationMs: Date.now() - start2
|
|
17260
|
+
};
|
|
17143
17261
|
}
|
|
17144
17262
|
if (!isYouTubeUrl(url)) {
|
|
17145
|
-
return {
|
|
17263
|
+
return {
|
|
17264
|
+
success: false,
|
|
17265
|
+
output: "",
|
|
17266
|
+
error: "Not a recognized YouTube URL. Supported: youtube.com/watch, youtu.be, shorts, live, embed",
|
|
17267
|
+
durationMs: Date.now() - start2
|
|
17268
|
+
};
|
|
17146
17269
|
}
|
|
17147
|
-
|
|
17148
|
-
|
|
17270
|
+
const ytDlp = ensureYtDlp();
|
|
17271
|
+
if (!ytDlp) {
|
|
17272
|
+
return {
|
|
17273
|
+
success: false,
|
|
17274
|
+
output: "",
|
|
17275
|
+
error: "yt-dlp not available via shared venv. Run: python3 -m venv ~/.open-agents/venv && ~/.open-agents/venv/bin/pip install yt-dlp",
|
|
17276
|
+
durationMs: Date.now() - start2
|
|
17277
|
+
};
|
|
17149
17278
|
}
|
|
17150
17279
|
mkdirSync9(outputDir, { recursive: true });
|
|
17151
17280
|
try {
|
|
17152
17281
|
let title = "download";
|
|
17153
17282
|
try {
|
|
17154
|
-
title = execSync13(`
|
|
17283
|
+
title = execSync13(`"${ytDlp}" --get-title "${url}"`, {
|
|
17284
|
+
timeout: 15e3,
|
|
17285
|
+
stdio: "pipe"
|
|
17286
|
+
}).toString().trim().replace(/[<>:"/\\|?*]/g, "_").slice(0, 100);
|
|
17155
17287
|
} catch {
|
|
17156
17288
|
}
|
|
17157
17289
|
if (format3 === "mp4") {
|
|
17158
17290
|
const outPath = join27(outputDir, `${title}.mp4`);
|
|
17159
17291
|
const outTemplate = join27(outputDir, `${title}.%(ext)s`);
|
|
17160
|
-
execSync13(`
|
|
17292
|
+
execSync13(`"${ytDlp}" -f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" --merge-output-format mp4 -o "${outTemplate}" "${url}"`, { timeout: 6e5, stdio: "pipe", cwd: outputDir });
|
|
17161
17293
|
const actualPath = existsSync22(outPath) ? outPath : outTemplate.replace("%(ext)s", "mp4");
|
|
17162
17294
|
return {
|
|
17163
17295
|
success: true,
|
|
@@ -17166,11 +17298,23 @@ Title: ${title}
|
|
|
17166
17298
|
Format: mp4`,
|
|
17167
17299
|
durationMs: Date.now() - start2
|
|
17168
17300
|
};
|
|
17301
|
+
} else if (format3 === "wav") {
|
|
17302
|
+
const outPath = join27(outputDir, `${title}.wav`);
|
|
17303
|
+
const outTemplate = join27(outputDir, `${title}.%(ext)s`);
|
|
17304
|
+
execSync13(`"${ytDlp}" -x --audio-format wav --audio-quality 0 -o "${outTemplate}" "${url}"`, { timeout: 6e5, stdio: "pipe", cwd: outputDir });
|
|
17305
|
+
const actualPath = existsSync22(outPath) ? outPath : outTemplate.replace("%(ext)s", "wav");
|
|
17306
|
+
return {
|
|
17307
|
+
success: true,
|
|
17308
|
+
output: `Downloaded audio: ${actualPath}
|
|
17309
|
+
Title: ${title}
|
|
17310
|
+
Format: wav`,
|
|
17311
|
+
durationMs: Date.now() - start2
|
|
17312
|
+
};
|
|
17169
17313
|
} else {
|
|
17170
17314
|
const outPath = join27(outputDir, `${title}.mp3`);
|
|
17171
17315
|
const outTemplate = join27(outputDir, `${title}.%(ext)s`);
|
|
17172
|
-
execSync13(`
|
|
17173
|
-
const actualPath = existsSync22(outPath) ? outPath : outTemplate.replace("%(ext)s", "mp3");
|
|
17316
|
+
execSync13(`"${ytDlp}" -x --audio-format mp3 --audio-quality 0 -o "${outTemplate}" "${url}"`, { timeout: 6e5, stdio: "pipe", cwd: outputDir });
|
|
17317
|
+
const actualPath = existsSync22(outPath) ? outPath : outTemplate.replace("%(ext)s)", "mp3");
|
|
17174
17318
|
return {
|
|
17175
17319
|
success: true,
|
|
17176
17320
|
output: `Downloaded audio: ${actualPath}
|
|
@@ -617741,6 +617885,9 @@ Rationale: ${proposal.rationale}${provenanceNote}`;
|
|
|
617741
617885
|
rl.setPreSubmit(() => statusBar.suggestAccept());
|
|
617742
617886
|
}
|
|
617743
617887
|
process.stdout.on("resize", () => {
|
|
617888
|
+
if (statusBar.isActive) {
|
|
617889
|
+
statusBar.reapplyScrollRegion();
|
|
617890
|
+
}
|
|
617744
617891
|
statusBar.handleResize();
|
|
617745
617892
|
setTermSize(process.stdout.rows ?? 24, process.stdout.columns ?? 80);
|
|
617746
617893
|
if (isNeovimActive()) {
|
|
@@ -32,7 +32,7 @@ from typing import Dict, Optional
|
|
|
32
32
|
# ──────────────────────────────────────────────────────────────
|
|
33
33
|
# 0) Embedded venv bootstrap (same pattern as other services)
|
|
34
34
|
# ──────────────────────────────────────────────────────────────
|
|
35
|
-
VENV_DIR = Path.
|
|
35
|
+
VENV_DIR = Path(__file__).resolve().parent / ".venv"
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def _in_venv() -> bool:
|
|
@@ -156,7 +156,12 @@ class Tools:
|
|
|
156
156
|
for path in filter(None, candidates):
|
|
157
157
|
if os.path.isfile(path) and os.access(path, os.X_OK):
|
|
158
158
|
try:
|
|
159
|
-
subprocess.run(
|
|
159
|
+
subprocess.run(
|
|
160
|
+
[path, "--version"],
|
|
161
|
+
check=True,
|
|
162
|
+
stdout=subprocess.DEVNULL,
|
|
163
|
+
stderr=subprocess.DEVNULL,
|
|
164
|
+
)
|
|
160
165
|
return path
|
|
161
166
|
except Exception:
|
|
162
167
|
continue
|
|
@@ -207,8 +212,12 @@ class Tools:
|
|
|
207
212
|
snap_drv = "/snap/chromium/current/usr/lib/chromium-browser/chromedriver"
|
|
208
213
|
if os.path.exists(snap_drv):
|
|
209
214
|
try:
|
|
210
|
-
log_message(
|
|
211
|
-
|
|
215
|
+
log_message(
|
|
216
|
+
f"[open_browser] Using snap chromedriver at {snap_drv}", "DEBUG"
|
|
217
|
+
)
|
|
218
|
+
Tools._driver = webdriver.Chrome(
|
|
219
|
+
service=Service(snap_drv), options=opts
|
|
220
|
+
)
|
|
212
221
|
log_message("[open_browser] Launched via snap chromedriver.", "SUCCESS")
|
|
213
222
|
return "Browser launched (snap chromedriver)"
|
|
214
223
|
except WebDriverException as e:
|
|
@@ -217,59 +226,92 @@ class Tools:
|
|
|
217
226
|
sys_drv = Tools._find_system_chromedriver()
|
|
218
227
|
if sys_drv:
|
|
219
228
|
try:
|
|
220
|
-
log_message(
|
|
229
|
+
log_message(
|
|
230
|
+
f"[open_browser] Trying system chromedriver at {sys_drv}", "DEBUG"
|
|
231
|
+
)
|
|
221
232
|
Tools._driver = webdriver.Chrome(service=Service(sys_drv), options=opts)
|
|
222
|
-
log_message(
|
|
233
|
+
log_message(
|
|
234
|
+
"[open_browser] Launched via system chromedriver.", "SUCCESS"
|
|
235
|
+
)
|
|
223
236
|
return "Browser launched (system chromedriver)"
|
|
224
237
|
except WebDriverException as e:
|
|
225
|
-
log_message(
|
|
238
|
+
log_message(
|
|
239
|
+
f"[open_browser] System chromedriver failed: {e}", "WARNING"
|
|
240
|
+
)
|
|
226
241
|
|
|
227
242
|
arch = (platform.machine() or "").lower()
|
|
228
243
|
if arch in ("aarch64", "arm64", "armv8l", "armv7l") and chrome_bin:
|
|
229
244
|
try:
|
|
230
|
-
raw =
|
|
245
|
+
raw = (
|
|
246
|
+
subprocess.check_output([chrome_bin, "--version"]).decode().strip()
|
|
247
|
+
)
|
|
231
248
|
ver = raw.split()[1]
|
|
232
249
|
url = (
|
|
233
250
|
f"https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/"
|
|
234
251
|
f"{ver}/linux-arm64/chromedriver-linux-arm64.zip"
|
|
235
252
|
)
|
|
236
253
|
tmp_zip = "/tmp/chromedriver_arm64.zip"
|
|
237
|
-
log_message(
|
|
254
|
+
log_message(
|
|
255
|
+
f"[open_browser] Downloading ARM64 driver from {url}", "DEBUG"
|
|
256
|
+
)
|
|
238
257
|
subprocess.check_call(["wget", "-qO", tmp_zip, url])
|
|
239
258
|
subprocess.check_call(["unzip", "-o", tmp_zip, "-d", "/tmp"])
|
|
240
|
-
subprocess.check_call(
|
|
241
|
-
|
|
259
|
+
subprocess.check_call(
|
|
260
|
+
["sudo", "mv", "/tmp/chromedriver", "/usr/local/bin/chromedriver"]
|
|
261
|
+
)
|
|
262
|
+
subprocess.check_call(
|
|
263
|
+
["sudo", "chmod", "+x", "/usr/local/bin/chromedriver"]
|
|
264
|
+
)
|
|
242
265
|
drv = shutil.which("chromedriver")
|
|
243
266
|
log_message(f"[open_browser] Installed ARM64 driver at {drv}", "DEBUG")
|
|
244
267
|
Tools._driver = webdriver.Chrome(service=Service(drv), options=opts)
|
|
245
|
-
log_message(
|
|
268
|
+
log_message(
|
|
269
|
+
"[open_browser] Launched via downloaded ARM64 chromedriver.",
|
|
270
|
+
"SUCCESS",
|
|
271
|
+
)
|
|
246
272
|
return "Browser launched (downloaded ARM64 chromedriver)"
|
|
247
273
|
except Exception as e:
|
|
248
|
-
log_message(
|
|
274
|
+
log_message(
|
|
275
|
+
f"[open_browser] ARM64 download/install failed: {e}", "WARNING"
|
|
276
|
+
)
|
|
249
277
|
|
|
250
278
|
if arch in ("x86_64", "amd64") and chrome_bin:
|
|
251
279
|
try:
|
|
252
|
-
raw =
|
|
280
|
+
raw = (
|
|
281
|
+
subprocess.check_output([chrome_bin, "--version"]).decode().strip()
|
|
282
|
+
)
|
|
253
283
|
browser_major = raw.split()[1].split(".")[0]
|
|
254
284
|
except Exception:
|
|
255
285
|
browser_major = "latest"
|
|
256
286
|
try:
|
|
257
|
-
log_message(
|
|
287
|
+
log_message(
|
|
288
|
+
f"[open_browser] Installing ChromeDriver {browser_major} via webdriver-manager",
|
|
289
|
+
"DEBUG",
|
|
290
|
+
)
|
|
258
291
|
drv_path = ChromeDriverManager(driver_version=browser_major).install()
|
|
259
|
-
Tools._driver = webdriver.Chrome(
|
|
292
|
+
Tools._driver = webdriver.Chrome(
|
|
293
|
+
service=Service(drv_path), options=opts
|
|
294
|
+
)
|
|
260
295
|
log_message("[open_browser] Launched via webdriver-manager.", "SUCCESS")
|
|
261
296
|
return "Browser launched (webdriver-manager)"
|
|
262
297
|
except Exception as e:
|
|
263
298
|
log_message(f"[open_browser] webdriver-manager failed: {e}", "ERROR")
|
|
264
299
|
|
|
265
300
|
try:
|
|
266
|
-
log_message(
|
|
301
|
+
log_message(
|
|
302
|
+
"[open_browser] Attempting `sudo snap install chromium`…", "DEBUG"
|
|
303
|
+
)
|
|
267
304
|
subprocess.check_call(["sudo", "snap", "install", "chromium"])
|
|
268
305
|
Tools._driver = webdriver.Chrome(service=Service(snap_drv), options=opts)
|
|
269
|
-
log_message(
|
|
306
|
+
log_message(
|
|
307
|
+
"[open_browser] Launched via newly-installed snap chromium.", "SUCCESS"
|
|
308
|
+
)
|
|
270
309
|
return "Browser launched (snap install fallback)"
|
|
271
310
|
except Exception as e:
|
|
272
|
-
log_message(
|
|
311
|
+
log_message(
|
|
312
|
+
f"[open_browser] Auto-snap install failed or Chrome still not found: {e}",
|
|
313
|
+
"ERROR",
|
|
314
|
+
)
|
|
273
315
|
|
|
274
316
|
raise RuntimeError(
|
|
275
317
|
"No usable Chrome/Chromium driver. Install Chrome and a matching chromedriver, "
|
|
@@ -306,10 +348,14 @@ class Tools:
|
|
|
306
348
|
return "Error: browser not open"
|
|
307
349
|
try:
|
|
308
350
|
drv = Tools._driver
|
|
309
|
-
el = WebDriverWait(drv, timeout).until(
|
|
351
|
+
el = WebDriverWait(drv, timeout).until(
|
|
352
|
+
EC.element_to_be_clickable((By.CSS_SELECTOR, selector))
|
|
353
|
+
)
|
|
310
354
|
drv.execute_script("arguments[0].scrollIntoView({block:'center'});", el)
|
|
311
355
|
el.click()
|
|
312
|
-
focused = drv.execute_script(
|
|
356
|
+
focused = drv.execute_script(
|
|
357
|
+
"return document.activeElement === arguments[0];", el
|
|
358
|
+
)
|
|
313
359
|
log_message(f"[click] {selector} clicked (focused={focused})", "DEBUG")
|
|
314
360
|
return f"Clicked {selector}"
|
|
315
361
|
except Exception as e:
|
|
@@ -322,7 +368,9 @@ class Tools:
|
|
|
322
368
|
return "Error: browser not open"
|
|
323
369
|
try:
|
|
324
370
|
drv = Tools._driver
|
|
325
|
-
el = WebDriverWait(drv, timeout).until(
|
|
371
|
+
el = WebDriverWait(drv, timeout).until(
|
|
372
|
+
EC.element_to_be_clickable((By.CSS_SELECTOR, selector))
|
|
373
|
+
)
|
|
326
374
|
drv.execute_script("arguments[0].scrollIntoView({block:'center'});", el)
|
|
327
375
|
el.clear()
|
|
328
376
|
el.send_keys(text + Keys.RETURN)
|
|
@@ -337,7 +385,9 @@ class Tools:
|
|
|
337
385
|
if not Tools._driver:
|
|
338
386
|
return ""
|
|
339
387
|
try:
|
|
340
|
-
dom = Tools._driver.execute_script(
|
|
388
|
+
dom = Tools._driver.execute_script(
|
|
389
|
+
"return document.documentElement.outerHTML;"
|
|
390
|
+
)
|
|
341
391
|
if dom and len(dom) > max_chars:
|
|
342
392
|
dom = dom[:max_chars]
|
|
343
393
|
return dom or ""
|
|
@@ -437,18 +487,29 @@ class Tools:
|
|
|
437
487
|
}
|
|
438
488
|
return { ok: true, cancelled };
|
|
439
489
|
"""
|
|
440
|
-
res = Tools._driver.execute_script(
|
|
490
|
+
res = Tools._driver.execute_script(
|
|
491
|
+
script, float(x), float(y), float(delta_x), float(delta_y)
|
|
492
|
+
)
|
|
441
493
|
if not isinstance(res, dict) or not res.get("ok"):
|
|
442
494
|
reason = res.get("reason") if isinstance(res, dict) else "unknown"
|
|
443
495
|
return f"Error scrolling at point: {reason}"
|
|
444
|
-
log_message(
|
|
496
|
+
log_message(
|
|
497
|
+
f"[scroll_at] wheel dx={delta_x:.2f} dy={delta_y:.2f} at ({x:.1f},{y:.1f})",
|
|
498
|
+
"DEBUG",
|
|
499
|
+
)
|
|
445
500
|
return "Scrolled at point"
|
|
446
501
|
except Exception as exc:
|
|
447
502
|
log_message(f"[scroll_at] failed: {exc}", "ERROR")
|
|
448
503
|
return f"Error scrolling at point: {exc}"
|
|
449
504
|
|
|
450
505
|
@staticmethod
|
|
451
|
-
def sync_input(
|
|
506
|
+
def sync_input(
|
|
507
|
+
value: str,
|
|
508
|
+
selector: str = "",
|
|
509
|
+
submit: bool = False,
|
|
510
|
+
input_type: str = "",
|
|
511
|
+
data: Optional[str] = None,
|
|
512
|
+
) -> str:
|
|
452
513
|
if not Tools._driver:
|
|
453
514
|
return "Error: browser not open"
|
|
454
515
|
try:
|
|
@@ -539,7 +600,14 @@ class Tools:
|
|
|
539
600
|
};
|
|
540
601
|
"""
|
|
541
602
|
|
|
542
|
-
res = drv.execute_script(
|
|
603
|
+
res = drv.execute_script(
|
|
604
|
+
script,
|
|
605
|
+
selector or "",
|
|
606
|
+
value or "",
|
|
607
|
+
bool(submit),
|
|
608
|
+
input_type or "",
|
|
609
|
+
data if data is not None else None,
|
|
610
|
+
)
|
|
543
611
|
if not isinstance(res, dict) or not res.get("ok"):
|
|
544
612
|
reason = res.get("reason") if isinstance(res, dict) else "sync_failed"
|
|
545
613
|
return f"Error syncing input: {reason}"
|
|
@@ -549,6 +617,7 @@ class Tools:
|
|
|
549
617
|
log_message(f"[sync_input] failed: {exc}", "ERROR")
|
|
550
618
|
return f"Error syncing input: {exc}"
|
|
551
619
|
|
|
620
|
+
|
|
552
621
|
# ──────────────────────────────────────────────────────────────
|
|
553
622
|
# 3) Environment configuration
|
|
554
623
|
# ──────────────────────────────────────────────────────────────
|
|
@@ -562,14 +631,28 @@ MAX_CONCURRENCY = max(4, int(os.getenv("SCRAPE_MAX_CONCURRENCY", "4")))
|
|
|
562
631
|
QUEUE_TIMEOUT_S = float(os.getenv("SCRAPE_QUEUE_TIMEOUT_S", "2.0"))
|
|
563
632
|
RATE_LIMIT_RPS = max(60, int(os.getenv("SCRAPE_RATE_LIMIT_RPS", "60")))
|
|
564
633
|
RATE_LIMIT_BURST = max(180, int(os.getenv("SCRAPE_RATE_LIMIT_BURST", "180")))
|
|
565
|
-
RATE_LIMIT_DISABLED = os.getenv("SCRAPE_RATE_LIMIT_DISABLED", "0").strip().lower() in (
|
|
566
|
-
|
|
634
|
+
RATE_LIMIT_DISABLED = os.getenv("SCRAPE_RATE_LIMIT_DISABLED", "0").strip().lower() in (
|
|
635
|
+
"1",
|
|
636
|
+
"true",
|
|
637
|
+
"yes",
|
|
638
|
+
"on",
|
|
639
|
+
)
|
|
640
|
+
RATE_LIMIT_LOCAL_BYPASS = os.getenv(
|
|
641
|
+
"SCRAPE_RATE_LIMIT_LOCAL_BYPASS", "1"
|
|
642
|
+
).strip().lower() in ("1", "true", "yes", "on")
|
|
567
643
|
RATE_LIMIT_WHITELIST = {
|
|
568
|
-
entry.strip()
|
|
644
|
+
entry.strip()
|
|
645
|
+
for entry in os.getenv("SCRAPE_RATE_LIMIT_WHITELIST", "").split(",")
|
|
646
|
+
if entry.strip()
|
|
569
647
|
}
|
|
570
648
|
FILE_TTL_S = max(60, int(os.getenv("SCRAPE_FILE_TTL_S", "900")))
|
|
571
649
|
FRAME_KEEPALIVE_S = max(10, int(os.getenv("SCRAPE_FRAME_KEEPALIVE_S", "45")))
|
|
572
|
-
HEADLESS_DEFAULT = os.getenv("SCRAPE_HEADLESS_DEFAULT", "1") in (
|
|
650
|
+
HEADLESS_DEFAULT = os.getenv("SCRAPE_HEADLESS_DEFAULT", "1") in (
|
|
651
|
+
"1",
|
|
652
|
+
"true",
|
|
653
|
+
"TRUE",
|
|
654
|
+
"yes",
|
|
655
|
+
)
|
|
573
656
|
|
|
574
657
|
app = Flask(__name__)
|
|
575
658
|
CORS(app, resources={r"/*": {"origins": "*"}})
|
|
@@ -588,7 +671,9 @@ _RATE_LOCK = threading.Lock()
|
|
|
588
671
|
def _slot(timeout: Optional[float] = None):
|
|
589
672
|
class _Slot:
|
|
590
673
|
def __init__(self, timeout_val: Optional[float]):
|
|
591
|
-
self.timeout = float(
|
|
674
|
+
self.timeout = float(
|
|
675
|
+
QUEUE_TIMEOUT_S if timeout_val is None else timeout_val
|
|
676
|
+
)
|
|
592
677
|
self.acquired = False
|
|
593
678
|
|
|
594
679
|
def __enter__(self):
|
|
@@ -705,7 +790,11 @@ def _now() -> float:
|
|
|
705
790
|
def _apply_rate_limit():
|
|
706
791
|
forwarded = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
|
707
792
|
ip = _sanitize_ip(forwarded or request.remote_addr or "0.0.0.0")
|
|
708
|
-
if
|
|
793
|
+
if (
|
|
794
|
+
RATE_LIMIT_DISABLED
|
|
795
|
+
or (RATE_LIMIT_LOCAL_BYPASS and _is_local_ip(ip))
|
|
796
|
+
or ip in RATE_LIMIT_WHITELIST
|
|
797
|
+
):
|
|
709
798
|
g.client_ip = ip
|
|
710
799
|
return
|
|
711
800
|
now = _now()
|
|
@@ -716,9 +805,16 @@ def _apply_rate_limit():
|
|
|
716
805
|
_RATE_BUCKETS[ip] = bucket
|
|
717
806
|
elapsed = max(0.0, now - bucket.get("ts", now))
|
|
718
807
|
bucket["ts"] = now
|
|
719
|
-
tokens = min(
|
|
808
|
+
tokens = min(
|
|
809
|
+
float(RATE_LIMIT_BURST),
|
|
810
|
+
float(bucket.get("tokens", RATE_LIMIT_BURST)) + elapsed * RATE_LIMIT_RPS,
|
|
811
|
+
)
|
|
720
812
|
if tokens < 1.0:
|
|
721
|
-
return
|
|
813
|
+
return (
|
|
814
|
+
jsonify({"ok": False, "error": "rate limit"}),
|
|
815
|
+
429,
|
|
816
|
+
{"Retry-After": "1"},
|
|
817
|
+
)
|
|
722
818
|
bucket["tokens"] = tokens - 1.0
|
|
723
819
|
g.client_ip = ip
|
|
724
820
|
|
|
@@ -730,7 +826,11 @@ def _auth_ok(req) -> bool:
|
|
|
730
826
|
if API_KEY and header_key and header_key == API_KEY:
|
|
731
827
|
return True
|
|
732
828
|
auth = (req.headers.get("Authorization") or "").strip()
|
|
733
|
-
if
|
|
829
|
+
if (
|
|
830
|
+
auth.lower().startswith("bearer ")
|
|
831
|
+
and API_KEY
|
|
832
|
+
and auth.split(None, 1)[1].strip() == API_KEY
|
|
833
|
+
):
|
|
734
834
|
return True
|
|
735
835
|
return False
|
|
736
836
|
|
|
@@ -787,6 +887,7 @@ def _shutdown_cleanup():
|
|
|
787
887
|
# Signal handlers: ensure Chrome is killed on SIGTERM/SIGINT
|
|
788
888
|
import signal as _signal
|
|
789
889
|
|
|
890
|
+
|
|
790
891
|
def _handle_terminate(signum, frame):
|
|
791
892
|
"""Graceful shutdown on SIGTERM/SIGINT — close Chrome then exit."""
|
|
792
893
|
try:
|
|
@@ -796,6 +897,7 @@ def _handle_terminate(signum, frame):
|
|
|
796
897
|
_CLEAN_STOP.set()
|
|
797
898
|
raise SystemExit(0)
|
|
798
899
|
|
|
900
|
+
|
|
799
901
|
_signal.signal(_signal.SIGTERM, _handle_terminate)
|
|
800
902
|
_signal.signal(_signal.SIGINT, _handle_terminate)
|
|
801
903
|
|
|
@@ -818,7 +920,13 @@ def _error(message: str, status: int = 400):
|
|
|
818
920
|
# ──────────────────────────────────────────────────────────────
|
|
819
921
|
@app.get("/health")
|
|
820
922
|
def health():
|
|
821
|
-
return jsonify(
|
|
923
|
+
return jsonify(
|
|
924
|
+
{
|
|
925
|
+
"status": "ok",
|
|
926
|
+
"browser_open": Tools.is_browser_open(),
|
|
927
|
+
"sessions": len(_SESSIONS),
|
|
928
|
+
}
|
|
929
|
+
)
|
|
822
930
|
|
|
823
931
|
|
|
824
932
|
@app.post("/session/start")
|
|
@@ -841,7 +949,16 @@ def session_start():
|
|
|
841
949
|
"headless": headless,
|
|
842
950
|
"frames": {},
|
|
843
951
|
}
|
|
844
|
-
_queue_event(
|
|
952
|
+
_queue_event(
|
|
953
|
+
sid,
|
|
954
|
+
{
|
|
955
|
+
"type": "status",
|
|
956
|
+
"msg": "browser_started",
|
|
957
|
+
"detail": msg,
|
|
958
|
+
"sid": sid,
|
|
959
|
+
"ts": int(time.time() * 1000),
|
|
960
|
+
},
|
|
961
|
+
)
|
|
845
962
|
return _ok(session_id=sid, message=msg, headless=headless)
|
|
846
963
|
|
|
847
964
|
|
|
@@ -865,7 +982,10 @@ def navigate():
|
|
|
865
982
|
return _error("missing url", 400)
|
|
866
983
|
with _slot():
|
|
867
984
|
msg = Tools.navigate(url)
|
|
868
|
-
_queue_event(
|
|
985
|
+
_queue_event(
|
|
986
|
+
data.get("sid") or next(iter(_SESSIONS), ""),
|
|
987
|
+
{"type": "status", "msg": msg, "ts": int(time.time() * 1000)},
|
|
988
|
+
)
|
|
869
989
|
if not _result_ok(msg):
|
|
870
990
|
return _error(msg, 500)
|
|
871
991
|
return _ok(message=msg)
|
|
@@ -883,7 +1003,10 @@ def click_selector():
|
|
|
883
1003
|
msg = Tools.click(selector)
|
|
884
1004
|
if not _result_ok(msg):
|
|
885
1005
|
return _error(msg, 500)
|
|
886
|
-
_queue_event(
|
|
1006
|
+
_queue_event(
|
|
1007
|
+
data.get("sid") or next(iter(_SESSIONS), ""),
|
|
1008
|
+
{"type": "status", "msg": msg, "ts": int(time.time() * 1000)},
|
|
1009
|
+
)
|
|
887
1010
|
return _ok(message=msg)
|
|
888
1011
|
|
|
889
1012
|
|
|
@@ -902,7 +1025,10 @@ def type_text():
|
|
|
902
1025
|
msg = Tools.input(selector, str(text))
|
|
903
1026
|
if not _result_ok(msg):
|
|
904
1027
|
return _error(msg, 500)
|
|
905
|
-
_queue_event(
|
|
1028
|
+
_queue_event(
|
|
1029
|
+
data.get("sid") or next(iter(_SESSIONS), ""),
|
|
1030
|
+
{"type": "status", "msg": msg, "ts": int(time.time() * 1000)},
|
|
1031
|
+
)
|
|
906
1032
|
return _ok(message=msg)
|
|
907
1033
|
|
|
908
1034
|
|
|
@@ -916,7 +1042,10 @@ def scroll():
|
|
|
916
1042
|
msg = Tools.scroll(amount)
|
|
917
1043
|
if not _result_ok(msg):
|
|
918
1044
|
return _error(msg, 500)
|
|
919
|
-
_queue_event(
|
|
1045
|
+
_queue_event(
|
|
1046
|
+
data.get("sid") or next(iter(_SESSIONS), ""),
|
|
1047
|
+
{"type": "status", "msg": msg, "ts": int(time.time() * 1000)},
|
|
1048
|
+
)
|
|
920
1049
|
return _ok(message=msg)
|
|
921
1050
|
|
|
922
1051
|
|
|
@@ -930,7 +1059,10 @@ def scroll_up():
|
|
|
930
1059
|
msg = Tools.scroll(-amount)
|
|
931
1060
|
if not _result_ok(msg):
|
|
932
1061
|
return _error(msg, 500)
|
|
933
|
-
_queue_event(
|
|
1062
|
+
_queue_event(
|
|
1063
|
+
data.get("sid") or next(iter(_SESSIONS), ""),
|
|
1064
|
+
{"type": "status", "msg": msg, "ts": int(time.time() * 1000)},
|
|
1065
|
+
)
|
|
934
1066
|
return _ok(message=msg)
|
|
935
1067
|
|
|
936
1068
|
|
|
@@ -944,7 +1076,10 @@ def scroll_down():
|
|
|
944
1076
|
msg = Tools.scroll(amount)
|
|
945
1077
|
if not _result_ok(msg):
|
|
946
1078
|
return _error(msg, 500)
|
|
947
|
-
_queue_event(
|
|
1079
|
+
_queue_event(
|
|
1080
|
+
data.get("sid") or next(iter(_SESSIONS), ""),
|
|
1081
|
+
{"type": "status", "msg": msg, "ts": int(time.time() * 1000)},
|
|
1082
|
+
)
|
|
948
1083
|
return _ok(message=msg)
|
|
949
1084
|
|
|
950
1085
|
|
|
@@ -960,8 +1095,12 @@ def scroll_point():
|
|
|
960
1095
|
delta_y = float(data.get("deltaY") or data.get("delta_y") or 0.0)
|
|
961
1096
|
viewport_w = float(data.get("viewportW") or data.get("viewport_width"))
|
|
962
1097
|
viewport_h = float(data.get("viewportH") or data.get("viewport_height"))
|
|
963
|
-
natural_w = float(
|
|
964
|
-
|
|
1098
|
+
natural_w = float(
|
|
1099
|
+
data.get("naturalW") or data.get("naturalWidth") or viewport_w
|
|
1100
|
+
)
|
|
1101
|
+
natural_h = float(
|
|
1102
|
+
data.get("naturalH") or data.get("naturalHeight") or viewport_h
|
|
1103
|
+
)
|
|
965
1104
|
except Exception:
|
|
966
1105
|
return _error("invalid scroll coordinates", 400)
|
|
967
1106
|
if viewport_w <= 0 or viewport_h <= 0:
|
|
@@ -972,7 +1111,7 @@ def scroll_point():
|
|
|
972
1111
|
vy = y * scale_y
|
|
973
1112
|
log_message(
|
|
974
1113
|
f"[scroll_point] ({x:.1f},{y:.1f}) scaled ({vx:.1f},{vy:.1f}) delta ({delta_x:.2f},{delta_y:.2f})",
|
|
975
|
-
"DEBUG"
|
|
1114
|
+
"DEBUG",
|
|
976
1115
|
)
|
|
977
1116
|
with _slot():
|
|
978
1117
|
msg = Tools.scroll_point(vx, vy, delta_x, delta_y)
|
|
@@ -1029,8 +1168,12 @@ def click_xy():
|
|
|
1029
1168
|
y = float(data.get("y"))
|
|
1030
1169
|
viewport_w = float(data.get("viewportW") or data.get("viewport_width"))
|
|
1031
1170
|
viewport_h = float(data.get("viewportH") or data.get("viewport_height"))
|
|
1032
|
-
natural_w = float(
|
|
1033
|
-
|
|
1171
|
+
natural_w = float(
|
|
1172
|
+
data.get("naturalW") or data.get("naturalWidth") or viewport_w
|
|
1173
|
+
)
|
|
1174
|
+
natural_h = float(
|
|
1175
|
+
data.get("naturalH") or data.get("naturalHeight") or viewport_h
|
|
1176
|
+
)
|
|
1034
1177
|
except Exception:
|
|
1035
1178
|
return _error("invalid coordinates", 400)
|
|
1036
1179
|
if viewport_w <= 0 or viewport_h <= 0:
|
|
@@ -1039,7 +1182,10 @@ def click_xy():
|
|
|
1039
1182
|
scale_y = natural_h / max(1.0, viewport_h)
|
|
1040
1183
|
vx = x * scale_x
|
|
1041
1184
|
vy = y * scale_y
|
|
1042
|
-
log_message(
|
|
1185
|
+
log_message(
|
|
1186
|
+
f"[click_xy] requested ({x:.1f}, {y:.1f}) → viewport ({vx:.1f},{vy:.1f})",
|
|
1187
|
+
"DEBUG",
|
|
1188
|
+
)
|
|
1043
1189
|
with _slot():
|
|
1044
1190
|
try:
|
|
1045
1191
|
drv = Tools._driver # type: ignore[attr-defined]
|
|
@@ -1092,7 +1238,9 @@ def click_xy():
|
|
|
1092
1238
|
float(vy),
|
|
1093
1239
|
)
|
|
1094
1240
|
if not result or not result.get("ok"):
|
|
1095
|
-
return _error(
|
|
1241
|
+
return _error(
|
|
1242
|
+
result.get("reason") if isinstance(result, dict) else "click failed", 500
|
|
1243
|
+
)
|
|
1096
1244
|
_queue_event(
|
|
1097
1245
|
data.get("sid") or next(iter(_SESSIONS), ""),
|
|
1098
1246
|
{
|
|
@@ -1120,7 +1268,13 @@ def input_sync():
|
|
|
1120
1268
|
data_snippet = data.get("data")
|
|
1121
1269
|
_touch_session(sid)
|
|
1122
1270
|
with _slot():
|
|
1123
|
-
msg = Tools.sync_input(
|
|
1271
|
+
msg = Tools.sync_input(
|
|
1272
|
+
value,
|
|
1273
|
+
selector=selector,
|
|
1274
|
+
submit=submit,
|
|
1275
|
+
input_type=input_type,
|
|
1276
|
+
data=data_snippet,
|
|
1277
|
+
)
|
|
1124
1278
|
if not _result_ok(msg):
|
|
1125
1279
|
return _error(msg, 500)
|
|
1126
1280
|
return _ok(message=msg)
|
|
@@ -1138,8 +1292,12 @@ def drag():
|
|
|
1138
1292
|
end_y = float(data.get("endY"))
|
|
1139
1293
|
viewport_w = float(data.get("viewportW") or data.get("viewport_width"))
|
|
1140
1294
|
viewport_h = float(data.get("viewportH") or data.get("viewport_height"))
|
|
1141
|
-
natural_w = float(
|
|
1142
|
-
|
|
1295
|
+
natural_w = float(
|
|
1296
|
+
data.get("naturalW") or data.get("naturalWidth") or viewport_w
|
|
1297
|
+
)
|
|
1298
|
+
natural_h = float(
|
|
1299
|
+
data.get("naturalH") or data.get("naturalHeight") or viewport_h
|
|
1300
|
+
)
|
|
1143
1301
|
except Exception:
|
|
1144
1302
|
return _error("invalid drag coordinates", 400)
|
|
1145
1303
|
if viewport_w <= 0 or viewport_h <= 0:
|
|
@@ -1152,7 +1310,7 @@ def drag():
|
|
|
1152
1310
|
end_vy = end_y * scale_y
|
|
1153
1311
|
log_message(
|
|
1154
1312
|
f"[drag] ({start_x:.1f},{start_y:.1f})→({end_x:.1f},{end_y:.1f}) viewport ({start_vx:.1f},{start_vy:.1f})→({end_vx:.1f},{end_vy:.1f})",
|
|
1155
|
-
"DEBUG"
|
|
1313
|
+
"DEBUG",
|
|
1156
1314
|
)
|
|
1157
1315
|
with _slot():
|
|
1158
1316
|
msg = Tools.drag(start_vx, start_vy, end_vx, end_vy)
|
|
@@ -1164,10 +1322,7 @@ def drag():
|
|
|
1164
1322
|
{
|
|
1165
1323
|
"type": "status",
|
|
1166
1324
|
"msg": msg,
|
|
1167
|
-
"detail": {
|
|
1168
|
-
"start": [start_vx, start_vy],
|
|
1169
|
-
"end": [end_vx, end_vy]
|
|
1170
|
-
},
|
|
1325
|
+
"detail": {"start": [start_vx, start_vy], "end": [end_vx, end_vy]},
|
|
1171
1326
|
"ts": int(time.time() * 1000),
|
|
1172
1327
|
},
|
|
1173
1328
|
)
|
|
@@ -1182,7 +1337,9 @@ def dom_snapshot():
|
|
|
1182
1337
|
if not html:
|
|
1183
1338
|
return _error("no dom (browser closed?)", 409)
|
|
1184
1339
|
sid = request.args.get("sid") or next(iter(_SESSIONS), "")
|
|
1185
|
-
_queue_event(
|
|
1340
|
+
_queue_event(
|
|
1341
|
+
sid, {"type": "dom", "chars": len(html), "ts": int(time.time() * 1000)}
|
|
1342
|
+
)
|
|
1186
1343
|
return _ok(dom=html, length=len(html))
|
|
1187
1344
|
|
|
1188
1345
|
|
|
@@ -1192,7 +1349,11 @@ def _record_frame_meta(sid: str, fname: str, width: int, height: int) -> None:
|
|
|
1192
1349
|
if not meta:
|
|
1193
1350
|
return
|
|
1194
1351
|
frames = meta.setdefault("frames", {})
|
|
1195
|
-
frames[fname] = {
|
|
1352
|
+
frames[fname] = {
|
|
1353
|
+
"ts": int(time.time() * 1000),
|
|
1354
|
+
"width": width,
|
|
1355
|
+
"height": height,
|
|
1356
|
+
}
|
|
1196
1357
|
|
|
1197
1358
|
|
|
1198
1359
|
@app.get("/screenshot")
|
|
@@ -1230,7 +1391,9 @@ def screenshot():
|
|
|
1230
1391
|
"ts": int(time.time() * 1000),
|
|
1231
1392
|
},
|
|
1232
1393
|
)
|
|
1233
|
-
return _ok(
|
|
1394
|
+
return _ok(
|
|
1395
|
+
file=rel_path, width=width, height=height, mime="image/png", b64=b64_data
|
|
1396
|
+
)
|
|
1234
1397
|
|
|
1235
1398
|
|
|
1236
1399
|
@app.get("/frames/<path:filename>")
|
|
@@ -1286,7 +1449,9 @@ def _unhandled(exc):
|
|
|
1286
1449
|
@app.after_request
|
|
1287
1450
|
def _default_headers(resp):
|
|
1288
1451
|
resp.headers.setdefault("Cache-Control", "no-store, max-age=0")
|
|
1289
|
-
resp.headers.setdefault(
|
|
1452
|
+
resp.headers.setdefault(
|
|
1453
|
+
"Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key"
|
|
1454
|
+
)
|
|
1290
1455
|
return resp
|
|
1291
1456
|
|
|
1292
1457
|
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-agents-ai",
|
|
3
|
-
"version": "0.187.
|
|
3
|
+
"version": "0.187.573",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "open-agents-ai",
|
|
9
|
-
"version": "0.187.
|
|
9
|
+
"version": "0.187.573",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "CC-BY-NC-4.0",
|
|
12
12
|
"dependencies": {
|
package/package.json
CHANGED