@melaya/runner 1.1.48 → 1.1.50
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/assistantHost.py +6 -1
- package/dist/connection.js +142 -4
- package/dist/localBacklinkFetch.py +138 -0
- package/localBacklinkFetch.py +138 -0
- package/package.json +3 -2
package/dist/assistantHost.py
CHANGED
|
@@ -170,6 +170,8 @@ def _config_hash() -> str:
|
|
|
170
170
|
pinned_tools = ",".join(sorted(t.lower() for t in raw_pinned.split(",") if t.strip()))
|
|
171
171
|
if pinned_tools:
|
|
172
172
|
fields.append(pinned_tools)
|
|
173
|
+
if os.environ.get("MEL_ASSISTANT_TELEMETRY_SURFACE") == "marketing":
|
|
174
|
+
fields.append("marketing-scoped-tools-v1")
|
|
173
175
|
canon = "|".join(fields)
|
|
174
176
|
return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:32]
|
|
175
177
|
|
|
@@ -607,6 +609,7 @@ def _build_agent():
|
|
|
607
609
|
# names are case-sensitive Python identifiers (unlike connector_services,
|
|
608
610
|
# which are lowercased service ids). Registered ACTIVE at boot below so the
|
|
609
611
|
# model calls them directly instead of search_tools/activate_tool.
|
|
612
|
+
marketing_tool_scope = os.environ.get("MEL_ASSISTANT_TELEMETRY_SURFACE") == "marketing"
|
|
610
613
|
pinned_tool_names = [t.strip() for t in os.environ.get("MEL_ASSISTANT_PINNED_TOOLS", "").split(",") if t.strip()]
|
|
611
614
|
# Self-arm the write-approval gate whenever connectors are active, so it can't
|
|
612
615
|
# be silently OFF on an older runner build that didn't set this env. Writes
|
|
@@ -659,11 +662,12 @@ def _build_agent():
|
|
|
659
662
|
include_categories=categories + core_categories + connector_services,
|
|
660
663
|
budget=_budget,
|
|
661
664
|
pinned_names=pinned_tool_names or None,
|
|
665
|
+
allowed_names=pinned_tool_names if marketing_tool_scope else None,
|
|
662
666
|
)
|
|
663
667
|
except Exception as exc:
|
|
664
668
|
_log(f"toolkit build failed (connectors={connector_services}, core): {exc}; retrying melaya_agent only")
|
|
665
669
|
try:
|
|
666
|
-
toolkit = build_toolkit(categories=["melaya_agent"])
|
|
670
|
+
toolkit = build_toolkit(names=[]) if marketing_tool_scope else build_toolkit(categories=["melaya_agent"])
|
|
667
671
|
except Exception:
|
|
668
672
|
toolkit = build_toolkit(names=[])
|
|
669
673
|
|
|
@@ -1302,6 +1306,7 @@ def main() -> int:
|
|
|
1302
1306
|
except Exception:
|
|
1303
1307
|
_log("restore failed:\n" + traceback.format_exc())
|
|
1304
1308
|
continue
|
|
1309
|
+
os.environ["MEL_APPROVAL_CONVERSATION_ID"] = str(req.get("conversation_id") or "")
|
|
1305
1310
|
turn_id = str(req.get("turnId") or "")
|
|
1306
1311
|
message = str(req.get("message") or "")
|
|
1307
1312
|
# Vision: base64 image blocks for THIS turn (validated server-side). Used
|
package/dist/connection.js
CHANGED
|
@@ -77,6 +77,11 @@ let warmSpareSeed = null;
|
|
|
77
77
|
// SYNCHRONOUSLY here closes that window.
|
|
78
78
|
const bootingAssistants = new Set();
|
|
79
79
|
const ASSISTANT_IDLE_MS = 15 * 60 * 1000;
|
|
80
|
+
// OOM backstop for the assistant stdout line-framer: if a single unterminated
|
|
81
|
+
// line (no newline) grows past this, flush + reset rather than let `+=` build an
|
|
82
|
+
// unbounded string. Generous — legitimate frames (tool results, transcripts) are
|
|
83
|
+
// far smaller; a line this large would exceed the socket buffer anyway.
|
|
84
|
+
const ASSISTANT_STDOUT_MAX_CARRY = 64 * 1024 * 1024;
|
|
80
85
|
export async function connect(opts) {
|
|
81
86
|
const spinner = ora("Connecting to Melaya...").start();
|
|
82
87
|
// Connect to the /api/v1/runner NAMESPACE on the existing Socket.IO server.
|
|
@@ -1324,11 +1329,25 @@ export async function connect(opts) {
|
|
|
1324
1329
|
console.log(chalk.hex("#7c6ff0")(` ◆ Assistant ${a.forward ? "session" : "spare"} ${a.sid.slice(0, 12)}… (${a.providerLabel})`));
|
|
1325
1330
|
proc.stdout?.on("data", (data) => {
|
|
1326
1331
|
session.lastActivity = Date.now();
|
|
1332
|
+
// O(n) line framing: accumulate, split ONCE, carry the trailing partial.
|
|
1333
|
+
// The old `buf += …; while(indexOf/slice)` loop re-sliced the WHOLE buffer
|
|
1334
|
+
// per line — O(n²) on a big burst (a large tool result / browser tree in
|
|
1335
|
+
// the transcript). That blocked the Node event loop long enough that
|
|
1336
|
+
// Socket.IO couldn't service the connection (send the engine.io pong), so
|
|
1337
|
+
// the server's pingTimeout dropped the socket MID-TURN — the "heartbeat
|
|
1338
|
+
// stops while working" failure. Keeping this O(n) means the heartbeat and
|
|
1339
|
+
// pong keep flowing no matter how much the host streams.
|
|
1327
1340
|
session.stdoutBuf += data.toString();
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1341
|
+
const parts = session.stdoutBuf.split("\n");
|
|
1342
|
+
session.stdoutBuf = parts.pop() ?? "";
|
|
1343
|
+
// OOM backstop: an unterminated line that keeps growing across chunks would
|
|
1344
|
+
// make the `+=` above quadratic again. Flush it as its own line and reset.
|
|
1345
|
+
if (session.stdoutBuf.length > ASSISTANT_STDOUT_MAX_CARRY) {
|
|
1346
|
+
parts.push(session.stdoutBuf);
|
|
1347
|
+
session.stdoutBuf = "";
|
|
1348
|
+
console.log(chalk.yellow(` [assistant-stdout] flushed oversized line (${(ASSISTANT_STDOUT_MAX_CARRY / 1048576) | 0}MB+) to protect the event loop`));
|
|
1349
|
+
}
|
|
1350
|
+
for (const line of parts) {
|
|
1332
1351
|
const t = line.trim();
|
|
1333
1352
|
// A protocol frame can arrive GLUED to a preceding raw print that had no
|
|
1334
1353
|
// trailing newline (agentscope prints the agent transcript to the same
|
|
@@ -1865,6 +1884,7 @@ export async function connect(opts) {
|
|
|
1865
1884
|
message: payload.message,
|
|
1866
1885
|
hitl_mode: hitlMode,
|
|
1867
1886
|
static_context: typeof payload.staticContext === "string" ? payload.staticContext : "",
|
|
1887
|
+
conversation_id: String(payload.sessionId || "").split(":").slice(1).join(":"),
|
|
1868
1888
|
telemetry_surface: typeof payload.telemetrySurface === "string" ? payload.telemetrySurface : "assistant",
|
|
1869
1889
|
// Per-turn browser grant: forwarded to the host as the raw compact
|
|
1870
1890
|
// JWS string so the Python browser toolkit can verify it again
|
|
@@ -2454,6 +2474,124 @@ export async function connect(opts) {
|
|
|
2454
2474
|
reply(false, undefined, e?.message || String(e));
|
|
2455
2475
|
}
|
|
2456
2476
|
});
|
|
2477
|
+
// ── Residential-IP page fetch (Melaya backlink presence check) ──────────────
|
|
2478
|
+
// The Melaya cloud runs from a datacenter IP that Cloudflare "managed" Turnstile
|
|
2479
|
+
// forces into an interactive challenge no headless engine clears. THIS machine
|
|
2480
|
+
// is on a residential IP, where the same real-browser fetch passes in seconds.
|
|
2481
|
+
// The server hands us ONE public https url; we fetch it with the local Scrapling
|
|
2482
|
+
// ladder (localBacklinkFetch.py) and return the HTML for the server's presence
|
|
2483
|
+
// scan. We receive only the url — no secrets, no internal hosts, no catalog data.
|
|
2484
|
+
socket.on("runner:backlink-fetch", async (payload, ack) => {
|
|
2485
|
+
const rid = payload?.requestId;
|
|
2486
|
+
if (!rid)
|
|
2487
|
+
return;
|
|
2488
|
+
ack?.({ ok: true });
|
|
2489
|
+
const reply = (r) => socket.emit("runner:backlink-fetch-result", {
|
|
2490
|
+
requestId: rid,
|
|
2491
|
+
ok: !r.error && !!r.html && !r.challenged,
|
|
2492
|
+
status: r.status ?? 0,
|
|
2493
|
+
engine: r.engine ?? null,
|
|
2494
|
+
challenged: !!r.challenged,
|
|
2495
|
+
html: r.html ?? "",
|
|
2496
|
+
finalUrl: r.finalUrl ?? payload.url,
|
|
2497
|
+
error: r.error,
|
|
2498
|
+
});
|
|
2499
|
+
// HTTPS-only, public url — defence in depth (the server already validated it).
|
|
2500
|
+
let u = null;
|
|
2501
|
+
try {
|
|
2502
|
+
u = new URL(payload.url);
|
|
2503
|
+
}
|
|
2504
|
+
catch {
|
|
2505
|
+
u = null;
|
|
2506
|
+
}
|
|
2507
|
+
if (!u || u.protocol !== "https:") {
|
|
2508
|
+
reply({ error: "invalid_url" });
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
try {
|
|
2512
|
+
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
2513
|
+
const { getLocalSharedVersion } = await import("./sharedVendor.js");
|
|
2514
|
+
const localVersion = getLocalSharedVersion();
|
|
2515
|
+
if (!localVersion) {
|
|
2516
|
+
reply({ error: "runner python runtime still setting up" });
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2519
|
+
const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
|
|
2520
|
+
console.log(chalk.gray(` [backlink-fetch venv] ${m}`)); });
|
|
2521
|
+
if (!env.ok) {
|
|
2522
|
+
reply({ error: `venv bootstrap failed: ${env.reason}` });
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
const { existsSync, copyFileSync, mkdirSync } = await import("fs");
|
|
2526
|
+
const workDir = join(tmpdir(), `melaya-blf-${Date.now()}`);
|
|
2527
|
+
mkdirSync(workDir, { recursive: true });
|
|
2528
|
+
const candidates = [
|
|
2529
|
+
join(__dirname, "localBacklinkFetch.py"),
|
|
2530
|
+
join(__dirname, "..", "localBacklinkFetch.py"),
|
|
2531
|
+
];
|
|
2532
|
+
let found = "";
|
|
2533
|
+
for (const c of candidates) {
|
|
2534
|
+
if (existsSync(c)) {
|
|
2535
|
+
found = c;
|
|
2536
|
+
break;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
if (!found) {
|
|
2540
|
+
reply({ error: "localBacklinkFetch.py not found — update @melaya/runner" });
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
const staged = join(workDir, "localBacklinkFetch.py");
|
|
2544
|
+
copyFileSync(found, staged);
|
|
2545
|
+
const certBundle = (await import("./pythonEnv.js")).getCertBundlePath();
|
|
2546
|
+
const sslEnv = certBundle
|
|
2547
|
+
? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {};
|
|
2548
|
+
const budget = Math.max(6, Math.min(75, Math.floor(payload.budgetSeconds || 40)));
|
|
2549
|
+
const proc = spawn(env.pythonPath, ["-u", staged], {
|
|
2550
|
+
env: { ...process.env, ...sslEnv },
|
|
2551
|
+
cwd: workDir,
|
|
2552
|
+
});
|
|
2553
|
+
let stdout = "";
|
|
2554
|
+
let stderr = "";
|
|
2555
|
+
let done = false;
|
|
2556
|
+
const finish = (r) => { if (done)
|
|
2557
|
+
return; done = true; reply(r); };
|
|
2558
|
+
// Hard wall-clock cap slightly above the fetch budget so a wedged browser
|
|
2559
|
+
// can't hold the session; the server has its own (budget+8s) timeout too.
|
|
2560
|
+
const killTimer = setTimeout(() => {
|
|
2561
|
+
try {
|
|
2562
|
+
proc.kill();
|
|
2563
|
+
}
|
|
2564
|
+
catch { /* already gone */ }
|
|
2565
|
+
finish({ error: "fetch timed out" });
|
|
2566
|
+
}, (budget + 10) * 1000);
|
|
2567
|
+
proc.stdout.on("data", (d) => { stdout += d.toString("utf-8"); });
|
|
2568
|
+
proc.stderr.on("data", (d) => { stderr += d.toString("utf-8"); });
|
|
2569
|
+
proc.on("error", (err) => { clearTimeout(killTimer); finish({ error: err?.message || String(err) }); });
|
|
2570
|
+
proc.on("close", (code) => {
|
|
2571
|
+
clearTimeout(killTimer);
|
|
2572
|
+
const jsonLine = stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean).reverse()[0] || "";
|
|
2573
|
+
let parsed = null;
|
|
2574
|
+
try {
|
|
2575
|
+
parsed = JSON.parse(jsonLine);
|
|
2576
|
+
}
|
|
2577
|
+
catch { /* handled below */ }
|
|
2578
|
+
if (parsed && typeof parsed === "object") {
|
|
2579
|
+
finish({ status: parsed.status, engine: parsed.engine, challenged: !!parsed.challenged, html: parsed.html || "", finalUrl: parsed.finalUrl });
|
|
2580
|
+
}
|
|
2581
|
+
else {
|
|
2582
|
+
finish({ error: `fetch exited ${code ?? "?"}${stderr ? `: ${stderr.slice(-200)}` : ""}` });
|
|
2583
|
+
}
|
|
2584
|
+
});
|
|
2585
|
+
try {
|
|
2586
|
+
proc.stdin.write(JSON.stringify({ url: u.href, budget_seconds: budget }));
|
|
2587
|
+
proc.stdin.end();
|
|
2588
|
+
}
|
|
2589
|
+
catch { /* proc.on('error') handles it */ }
|
|
2590
|
+
}
|
|
2591
|
+
catch (e) {
|
|
2592
|
+
reply({ error: e?.message || String(e) });
|
|
2593
|
+
}
|
|
2594
|
+
});
|
|
2457
2595
|
socket.on("linkedin:start-login", async (req) => {
|
|
2458
2596
|
const sid = req?.session_id;
|
|
2459
2597
|
if (!sid)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Residential-IP page fetch for the Melaya backlink presence check.
|
|
3
|
+
|
|
4
|
+
The Melaya cloud runs from a datacenter IP that Cloudflare "managed" Turnstile
|
|
5
|
+
forces into an interactive challenge no headless engine clears. The user's
|
|
6
|
+
runner is on a RESIDENTIAL IP, where the same real-browser fetch passes in
|
|
7
|
+
seconds. So the server hands us ONE public https url; we fetch it with the
|
|
8
|
+
local Scrapling stack and print the result as a single JSON line on stdout:
|
|
9
|
+
|
|
10
|
+
{"status": int, "engine": str|null, "challenged": bool,
|
|
11
|
+
"finalUrl": str, "html": str} # html capped
|
|
12
|
+
|
|
13
|
+
Input (stdin, JSON): {"url": "...", "budget_seconds": 40}
|
|
14
|
+
|
|
15
|
+
Self-contained on purpose (mirrors localDbProbe.py): it imports only Scrapling
|
|
16
|
+
from the runner venv, never the shared tool bundle. HTTPS-only; the server
|
|
17
|
+
already validated the url, this is defence in depth. Never fetches anything but
|
|
18
|
+
the url it is given — no internal hosts, no secrets, no catalog data.
|
|
19
|
+
"""
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
_HTML_CAP = 600_000
|
|
25
|
+
_CHALLENGE_MARKERS = (
|
|
26
|
+
"just a moment", "cf_chl", "cf-challenge", "checking your browser",
|
|
27
|
+
"enable javascript and cookies", "attention required",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _html_of(page) -> str:
|
|
32
|
+
for attr in ("html_content", "body", "content"):
|
|
33
|
+
try:
|
|
34
|
+
v = getattr(page, attr)
|
|
35
|
+
if callable(v):
|
|
36
|
+
v = v()
|
|
37
|
+
if v:
|
|
38
|
+
return v if isinstance(v, str) else str(v)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
try:
|
|
42
|
+
return str(page)
|
|
43
|
+
except Exception:
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _challenged(h: str) -> bool:
|
|
48
|
+
h = (h or "").lower()
|
|
49
|
+
return any(m in h for m in _CHALLENGE_MARKERS)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _fetch(url: str, budget: float) -> dict:
|
|
53
|
+
ms = int(max(6.0, min(75.0, budget)) * 1000)
|
|
54
|
+
# ── Tier 1: TLS-impersonating async GET (no browser) ──
|
|
55
|
+
try:
|
|
56
|
+
from scrapling.fetchers import AsyncFetcher
|
|
57
|
+
p = await AsyncFetcher.get(url, timeout=min(ms, 15000), impersonate="chrome",
|
|
58
|
+
stealthy_headers=True, follow_redirects=True)
|
|
59
|
+
st = int(getattr(p, "status", 0) or 0)
|
|
60
|
+
h = _html_of(p)
|
|
61
|
+
final = str(getattr(p, "url", "") or url)
|
|
62
|
+
if 200 <= st < 300 and h and not _challenged(h):
|
|
63
|
+
return {"status": st, "engine": "fast", "challenged": False,
|
|
64
|
+
"finalUrl": final, "html": h[:_HTML_CAP]}
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
# ── Tier 2/3: stealth browser (real Chrome), plain then solve_cloudflare ──
|
|
69
|
+
try:
|
|
70
|
+
from scrapling.fetchers import StealthyFetcher
|
|
71
|
+
except Exception:
|
|
72
|
+
return {"status": 0, "engine": None, "challenged": False, "finalUrl": url, "html": ""}
|
|
73
|
+
fn = getattr(StealthyFetcher, "async_fetch", None)
|
|
74
|
+
if fn is None:
|
|
75
|
+
return {"status": 0, "engine": None, "challenged": False, "finalUrl": url, "html": ""}
|
|
76
|
+
|
|
77
|
+
last = {"status": 0, "engine": "stealth", "challenged": True, "finalUrl": url, "html": ""}
|
|
78
|
+
for solve in (False, True):
|
|
79
|
+
base = {"headless": True, "timeout": 60000 if solve else min(ms, 25000)}
|
|
80
|
+
if solve:
|
|
81
|
+
base.update(solve_cloudflare=True, block_webrtc=True, disable_resources=False)
|
|
82
|
+
page = None
|
|
83
|
+
engine = ("real_chrome_cf" if solve else "real_chrome")
|
|
84
|
+
for use_real in (True, False):
|
|
85
|
+
kw = dict(base)
|
|
86
|
+
if use_real:
|
|
87
|
+
kw["real_chrome"] = True
|
|
88
|
+
else:
|
|
89
|
+
engine = ("chromium_cf" if solve else "chromium")
|
|
90
|
+
try:
|
|
91
|
+
page = await fn(url, **kw)
|
|
92
|
+
break
|
|
93
|
+
except Exception:
|
|
94
|
+
page = None
|
|
95
|
+
continue
|
|
96
|
+
if page is None:
|
|
97
|
+
continue
|
|
98
|
+
h = _html_of(page)
|
|
99
|
+
st = int(getattr(page, "status", 0) or 0)
|
|
100
|
+
final = str(getattr(page, "url", "") or url)
|
|
101
|
+
last = {"status": st, "engine": engine, "challenged": _challenged(h),
|
|
102
|
+
"finalUrl": final, "html": (h or "")[:_HTML_CAP]}
|
|
103
|
+
if h and not _challenged(h):
|
|
104
|
+
return last
|
|
105
|
+
if not solve and not _challenged(h):
|
|
106
|
+
break # plain stealth returned something non-challenge-y but empty
|
|
107
|
+
return last
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _main() -> None:
|
|
111
|
+
try:
|
|
112
|
+
data = json.loads(sys.stdin.read() or "{}")
|
|
113
|
+
except Exception:
|
|
114
|
+
data = {}
|
|
115
|
+
url = str(data.get("url") or "").strip()
|
|
116
|
+
try:
|
|
117
|
+
budget = float(data.get("budget_seconds") or 40)
|
|
118
|
+
except Exception:
|
|
119
|
+
budget = 40.0
|
|
120
|
+
from urllib.parse import urlsplit
|
|
121
|
+
if not url or urlsplit(url).scheme != "https":
|
|
122
|
+
print(json.dumps({"status": 0, "engine": None, "challenged": False,
|
|
123
|
+
"finalUrl": url, "html": "", "error": "https_only"}))
|
|
124
|
+
return
|
|
125
|
+
try:
|
|
126
|
+
out = await _fetch(url, budget)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
out = {"status": 0, "engine": None, "challenged": False,
|
|
129
|
+
"finalUrl": url, "html": "", "error": str(e)[:200]}
|
|
130
|
+
print(json.dumps(out))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if sys.platform.startswith("win"):
|
|
134
|
+
try:
|
|
135
|
+
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
asyncio.run(_main())
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Residential-IP page fetch for the Melaya backlink presence check.
|
|
3
|
+
|
|
4
|
+
The Melaya cloud runs from a datacenter IP that Cloudflare "managed" Turnstile
|
|
5
|
+
forces into an interactive challenge no headless engine clears. The user's
|
|
6
|
+
runner is on a RESIDENTIAL IP, where the same real-browser fetch passes in
|
|
7
|
+
seconds. So the server hands us ONE public https url; we fetch it with the
|
|
8
|
+
local Scrapling stack and print the result as a single JSON line on stdout:
|
|
9
|
+
|
|
10
|
+
{"status": int, "engine": str|null, "challenged": bool,
|
|
11
|
+
"finalUrl": str, "html": str} # html capped
|
|
12
|
+
|
|
13
|
+
Input (stdin, JSON): {"url": "...", "budget_seconds": 40}
|
|
14
|
+
|
|
15
|
+
Self-contained on purpose (mirrors localDbProbe.py): it imports only Scrapling
|
|
16
|
+
from the runner venv, never the shared tool bundle. HTTPS-only; the server
|
|
17
|
+
already validated the url, this is defence in depth. Never fetches anything but
|
|
18
|
+
the url it is given — no internal hosts, no secrets, no catalog data.
|
|
19
|
+
"""
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
_HTML_CAP = 600_000
|
|
25
|
+
_CHALLENGE_MARKERS = (
|
|
26
|
+
"just a moment", "cf_chl", "cf-challenge", "checking your browser",
|
|
27
|
+
"enable javascript and cookies", "attention required",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _html_of(page) -> str:
|
|
32
|
+
for attr in ("html_content", "body", "content"):
|
|
33
|
+
try:
|
|
34
|
+
v = getattr(page, attr)
|
|
35
|
+
if callable(v):
|
|
36
|
+
v = v()
|
|
37
|
+
if v:
|
|
38
|
+
return v if isinstance(v, str) else str(v)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
try:
|
|
42
|
+
return str(page)
|
|
43
|
+
except Exception:
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _challenged(h: str) -> bool:
|
|
48
|
+
h = (h or "").lower()
|
|
49
|
+
return any(m in h for m in _CHALLENGE_MARKERS)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _fetch(url: str, budget: float) -> dict:
|
|
53
|
+
ms = int(max(6.0, min(75.0, budget)) * 1000)
|
|
54
|
+
# ── Tier 1: TLS-impersonating async GET (no browser) ──
|
|
55
|
+
try:
|
|
56
|
+
from scrapling.fetchers import AsyncFetcher
|
|
57
|
+
p = await AsyncFetcher.get(url, timeout=min(ms, 15000), impersonate="chrome",
|
|
58
|
+
stealthy_headers=True, follow_redirects=True)
|
|
59
|
+
st = int(getattr(p, "status", 0) or 0)
|
|
60
|
+
h = _html_of(p)
|
|
61
|
+
final = str(getattr(p, "url", "") or url)
|
|
62
|
+
if 200 <= st < 300 and h and not _challenged(h):
|
|
63
|
+
return {"status": st, "engine": "fast", "challenged": False,
|
|
64
|
+
"finalUrl": final, "html": h[:_HTML_CAP]}
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
# ── Tier 2/3: stealth browser (real Chrome), plain then solve_cloudflare ──
|
|
69
|
+
try:
|
|
70
|
+
from scrapling.fetchers import StealthyFetcher
|
|
71
|
+
except Exception:
|
|
72
|
+
return {"status": 0, "engine": None, "challenged": False, "finalUrl": url, "html": ""}
|
|
73
|
+
fn = getattr(StealthyFetcher, "async_fetch", None)
|
|
74
|
+
if fn is None:
|
|
75
|
+
return {"status": 0, "engine": None, "challenged": False, "finalUrl": url, "html": ""}
|
|
76
|
+
|
|
77
|
+
last = {"status": 0, "engine": "stealth", "challenged": True, "finalUrl": url, "html": ""}
|
|
78
|
+
for solve in (False, True):
|
|
79
|
+
base = {"headless": True, "timeout": 60000 if solve else min(ms, 25000)}
|
|
80
|
+
if solve:
|
|
81
|
+
base.update(solve_cloudflare=True, block_webrtc=True, disable_resources=False)
|
|
82
|
+
page = None
|
|
83
|
+
engine = ("real_chrome_cf" if solve else "real_chrome")
|
|
84
|
+
for use_real in (True, False):
|
|
85
|
+
kw = dict(base)
|
|
86
|
+
if use_real:
|
|
87
|
+
kw["real_chrome"] = True
|
|
88
|
+
else:
|
|
89
|
+
engine = ("chromium_cf" if solve else "chromium")
|
|
90
|
+
try:
|
|
91
|
+
page = await fn(url, **kw)
|
|
92
|
+
break
|
|
93
|
+
except Exception:
|
|
94
|
+
page = None
|
|
95
|
+
continue
|
|
96
|
+
if page is None:
|
|
97
|
+
continue
|
|
98
|
+
h = _html_of(page)
|
|
99
|
+
st = int(getattr(page, "status", 0) or 0)
|
|
100
|
+
final = str(getattr(page, "url", "") or url)
|
|
101
|
+
last = {"status": st, "engine": engine, "challenged": _challenged(h),
|
|
102
|
+
"finalUrl": final, "html": (h or "")[:_HTML_CAP]}
|
|
103
|
+
if h and not _challenged(h):
|
|
104
|
+
return last
|
|
105
|
+
if not solve and not _challenged(h):
|
|
106
|
+
break # plain stealth returned something non-challenge-y but empty
|
|
107
|
+
return last
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _main() -> None:
|
|
111
|
+
try:
|
|
112
|
+
data = json.loads(sys.stdin.read() or "{}")
|
|
113
|
+
except Exception:
|
|
114
|
+
data = {}
|
|
115
|
+
url = str(data.get("url") or "").strip()
|
|
116
|
+
try:
|
|
117
|
+
budget = float(data.get("budget_seconds") or 40)
|
|
118
|
+
except Exception:
|
|
119
|
+
budget = 40.0
|
|
120
|
+
from urllib.parse import urlsplit
|
|
121
|
+
if not url or urlsplit(url).scheme != "https":
|
|
122
|
+
print(json.dumps({"status": 0, "engine": None, "challenged": False,
|
|
123
|
+
"finalUrl": url, "html": "", "error": "https_only"}))
|
|
124
|
+
return
|
|
125
|
+
try:
|
|
126
|
+
out = await _fetch(url, budget)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
out = {"status": 0, "engine": None, "challenged": False,
|
|
129
|
+
"finalUrl": url, "html": "", "error": str(e)[:200]}
|
|
130
|
+
print(json.dumps(out))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if sys.platform.startswith("win"):
|
|
134
|
+
try:
|
|
135
|
+
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
asyncio.run(_main())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@melaya/runner",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.50",
|
|
4
4
|
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
"localRagIngest.py",
|
|
17
17
|
"localRagRetrieve.py",
|
|
18
18
|
"localDbProbe.py",
|
|
19
|
+
"localBacklinkFetch.py",
|
|
19
20
|
"nltk_data/**",
|
|
20
21
|
"README.md"
|
|
21
22
|
],
|
|
22
23
|
"scripts": {
|
|
23
|
-
"build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('localDbProbe.py','dist/localDbProbe.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
|
|
24
|
+
"build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('localDbProbe.py','dist/localDbProbe.py'); fs.copyFileSync('localBacklinkFetch.py','dist/localBacklinkFetch.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
|
|
24
25
|
"test": "node --test --import tsx \"src/**/*.test.ts\"",
|
|
25
26
|
"prepublishOnly": "npm run build"
|
|
26
27
|
},
|