Загрузка данных
'use strict';
/**
* i-am-not-a-robot live relay — raw CDP over a WebSocket (NO playwright),
* streamed to the phone over a WebSocket transport.
*
* Streams a real browser page to the user's phone via CDP Page.captureScreenshot
* and forwards the human's taps back into the SAME browser session via
* Input.dispatchMouseEvent, so a real finger solves the in-page CAPTCHA.
*
* Transport is a single WebSocket:
* frames down as binary JPEG
* taps up as JSON
*
* CAPTCHA is solved by the human. This relay does not automate CAPTCHA solving.
*
* Runtime deps: ws
* Logs go to stderr; stdout is reserved for the MCP JSON-RPC channel.
*/
const http = require('http');
const os = require('os');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const WebSocket = require('ws');
const { WebSocketServer } = WebSocket;
const TAG = '[i-am-not-a-robot:relay]';
/**
* Structured logger -> stderr only.
*/
function makeLogger(enabled) {
const on =
enabled &&
process.env.IAMNOTAROBOT_QUIET !== '1';
return (level, msg, extra) => {
if (!on) return;
const line =
`${TAG} ${level.toUpperCase()} ${msg}`;
if (extra !== undefined) {
console.error(line, extra);
} else {
console.error(line);
}
};
}
/**
* All non-internal IPv4 addresses.
*
* RFC1918 LAN addresses are preferred.
* Tailscale CGNAT is ranked last.
* Docker/veth/bridge interfaces and link-local addresses are skipped.
*/
function listIpv4() {
const out = [];
const ifaces = os.networkInterfaces();
for (const name of Object.keys(ifaces)) {
if (
/^(docker|veth|br-|virbr|cni|flannel)/i.test(name)
) {
continue;
}
for (const net of ifaces[name] || []) {
if (
net.family === 'IPv4' &&
!net.internal &&
!/^169\.254\./.test(net.address)
) {
out.push(net.address);
}
}
}
const rank = (ip) => {
if (/^192\.168\./.test(ip)) return 0;
if (/^10\./.test(ip)) return 0;
if (
/^172\.(1[6-9]|2\d|3[01])\./.test(ip)
) {
return 0;
}
if (
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip)
) {
return 2;
}
return 1;
};
out.sort((a, b) => rank(a) - rank(b));
return out.length
? out
: ['127.0.0.1'];
}
/**
* Primary LAN IP.
*/
function detectLanIp() {
return listIpv4()[0];
}
/**
* Minimal raw CDP client over a page-level WebSocket.
*
* Features:
* - request/response matching
* - per-command timeout
* - pending request cleanup
* - event handlers
* - safe close
*/
class CdpSession {
constructor(wsUrl, log, options = {}) {
this.wsUrl = wsUrl;
this.log = log || (() => {});
this.ws = null;
this._id = 0;
this._pending = new Map();
this._handlers = new Map();
this.closed = false;
this.connecting = false;
this.defaultTimeoutMs =
Number.isFinite(options.defaultTimeoutMs)
? options.defaultTimeoutMs
: 5000;
}
connect() {
if (
this.ws &&
this.ws.readyState === WebSocket.OPEN
) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
this.connecting = true;
this.closed = false;
this.log('external', 'CDP connecting', {
wsUrl: this.wsUrl,
});
const ws = new WebSocket(this.wsUrl, {
maxPayload: 64 * 1024 * 1024,
});
this.ws = ws;
let settled = false;
const finishConnect = (err) => {
if (settled) return;
settled = true;
this.connecting = false;
if (err) {
reject(err);
} else {
resolve();
}
};
ws.on('open', () => {
this.log('state', 'CDP ws open');
finishConnect();
});
ws.on('message', (data) => {
this._onMessage(data);
});
ws.on('error', (err) => {
this.log(
'error',
'CDP ws error',
err && err.message
);
finishConnect(err);
});
ws.on('close', () => {
this.closed = true;
this.connecting = false;
this.log('state', 'CDP ws closed');
for (const pending of this._pending.values()) {
try {
clearTimeout(pending.timer);
} catch {}
try {
pending.reject(
new Error('CDP socket closed')
);
} catch {}
}
this._pending.clear();
});
});
}
_onMessage(data) {
let msg;
try {
msg = JSON.parse(data.toString());
} catch {
this.log(
'error',
'CDP non-JSON frame ignored'
);
return;
}
if (
msg.id !== undefined &&
this._pending.has(msg.id)
) {
const pending =
this._pending.get(msg.id);
this._pending.delete(msg.id);
try {
clearTimeout(pending.timer);
} catch {}
if (msg.error) {
pending.reject(
new Error(
`CDP error: ${
msg.error.message ||
JSON.stringify(msg.error)
}`
)
);
} else {
pending.resolve(msg.result);
}
return;
}
if (!msg.method) return;
const handlers =
this._handlers.get(msg.method);
if (!handlers) return;
for (const handler of handlers) {
try {
handler(msg.params || {});
} catch (e) {
this.log(
'error',
'CDP event handler threw',
e && e.message
);
}
}
}
send(method, params = {}, options = {}) {
const timeoutMs =
Number.isFinite(options.timeoutMs)
? options.timeoutMs
: this.defaultTimeoutMs;
return new Promise((resolve, reject) => {
if (
!this.ws ||
this.ws.readyState !== WebSocket.OPEN
) {
reject(
new Error('CDP socket not open')
);
return;
}
if (this.closed) {
reject(
new Error('CDP session is closed')
);
return;
}
const id = ++this._id;
const timer = setTimeout(() => {
const pending =
this._pending.get(id);
if (!pending) return;
this._pending.delete(id);
this.log(
'warn',
'CDP command timeout',
{
id,
method,
timeoutMs,
}
);
reject(
new Error(
`CDP command timeout: ${method} (${timeoutMs}ms)`
)
);
}, timeoutMs);
this._pending.set(id, {
resolve,
reject,
timer,
method,
});
try {
this.ws.send(
JSON.stringify({
id,
method,
params,
}),
(err) => {
if (!err) return;
const pending =
this._pending.get(id);
if (!pending) return;
this._pending.delete(id);
try {
clearTimeout(pending.timer);
} catch {}
reject(err);
}
);
} catch (e) {
this._pending.delete(id);
try {
clearTimeout(timer);
} catch {}
reject(e);
}
});
}
on(method, handler) {
if (!this._handlers.has(method)) {
this._handlers.set(
method,
new Set()
);
}
this._handlers
.get(method)
.add(handler);
return () => {
const handlers =
this._handlers.get(method);
if (!handlers) return;
handlers.delete(handler);
if (!handlers.size) {
this._handlers.delete(method);
}
};
}
async close() {
this.closed = true;
for (const pending of this._pending.values()) {
try {
clearTimeout(pending.timer);
} catch {}
try {
pending.reject(
new Error('CDP session closed')
);
} catch {}
}
this._pending.clear();
this._handlers.clear();
const ws = this.ws;
this.ws = null;
if (!ws) return;
try {
if (
ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING
) {
ws.close();
}
} catch {}
}
}
/**
* List inspectable targets from CDP HTTP endpoint.
*/
async function fetchTargets(cdpUrl) {
const base =
cdpUrl.replace(/\/+$/, '');
const url = `${base}/json`;
const ac = new AbortController();
const timer = setTimeout(
() => ac.abort(),
8000
);
let res;
try {
res = await fetch(url, {
redirect: 'manual',
signal: ac.signal,
});
} catch (e) {
throw new Error(
`cannot reach CDP at ${url}: ${e.message}`
);
} finally {
clearTimeout(timer);
}
if (!res.ok) {
throw new Error(
`CDP ${url} returned HTTP ${res.status}`
);
}
return res.json();
}
/**
* Strip userinfo from URL before logging.
*/
function redactUrl(u) {
try {
const x = new URL(u);
if (x.username || x.password) {
x.username = '';
x.password = '';
return x.toString();
}
return u;
} catch {
return u;
}
}
/**
* True if the debugger ws uses the same port as cdpUrl.
*
* We deliberately compare the port only.
* localhost / 127.0.0.1 / tunnelled hosts can legitimately differ.
*/
function sameCdpHost(cdpUrl, wsUrl) {
try {
return (
new URL(cdpUrl).port ===
new URL(wsUrl).port
);
} catch {
return false;
}
}
/**
* Phone page.
*
* Binary JPEG frames come down.
* Normalized tap coordinates go back up.
*/
function renderPhonePage(title) {
const esc = (s) =>
String(s).replace(
/[&<>"]/g,
(c) =>
({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
})[c]
);
title = esc(title);
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover"
>
<title>i-am-not-a-robot</title>
<style>
*{box-sizing:border-box}
html,body{
margin:0;
min-height:100%;
background:#0b0b0f;
color:#e8e8ea;
font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
-webkit-tap-highlight-color:transparent;
}
body{
min-height:100vh;
}
header{
position:sticky;
top:0;
z-index:2;
display:flex;
align-items:center;
gap:9px;
padding:11px 13px;
background:#15151c;
border-bottom:1px solid #26262f;
font-size:13px;
}
.dot{
width:9px;
height:9px;
border-radius:50%;
background:#f59e0b;
flex:0 0 auto;
transition:background .3s;
}
.dot.live{background:#22c55e}
.dot.off{background:#ef4444}
.wrap{
position:relative;
width:100%;
min-height:0;
touch-action:manipulation;
overflow:hidden;
}
img{
width:100%;
height:auto;
min-height:1px;
display:block;
object-fit:contain;
user-select:none;
-webkit-user-select:none;
-webkit-touch-callout:none;
}
#hint{
padding:11px 14px;
font-size:12px;
color:#9aa0aa;
text-align:center;
line-height:1.45;
}
.ring{
position:absolute;
width:34px;
height:34px;
margin:-17px 0 0 -17px;
border:2px solid #22c55e;
border-radius:50%;
pointer-events:none;
opacity:0;
}
.ring.go{
animation:rng .5s ease-out;
}
@keyframes rng{
0%{
opacity:.9;
transform:scale(.4);
}
100%{
opacity:0;
transform:scale(1.3);
}
}
</style>
</head>
<body>
<header>
<span class="dot" id="dot"></span>
<span>${title}</span>
</header>
<div class="wrap" id="wrap">
<img id="v" alt="live page" src="">
<div class="ring" id="ring"></div>
</div>
<div id="hint">
Tap exactly where you would on a computer.
When the challenge clears, you can close this page.
</div>
<script>
var img=document.getElementById('v');
var dot=document.getElementById('dot');
var ring=document.getElementById('ring');
var wrap=document.getElementById('wrap');
var hint=document.getElementById('hint');
var base=location.pathname.replace(/\\/$/,'');
var solved=false;
var curUrl=null;
var ws=new WebSocket(
(location.protocol==='https:'?'wss://':'ws://')+
location.host+
base+
'/ws'
);
ws.binaryType='blob';
ws.onmessage=function(e){
if(typeof e.data==='string'){
try{
var m=JSON.parse(e.data);
if(m.type==='solved'){
solved=true;
dot.className='dot live';
img.style.opacity='0.35';
hint.textContent=
'\\u2713 Solved — the agent has it. You can close this page.';
hint.style.color='#22c55e';
hint.style.fontSize='15px';
}
}catch(_){}
return;
}
if(solved) return;
var url=URL.createObjectURL(e.data);
var old=curUrl;
curUrl=url;
img.src=url;
dot.className='dot live';
if(old){
setTimeout(function(){
URL.revokeObjectURL(old);
},200);
}
};
ws.onclose=function(){
if(!solved){
dot.className='dot off';
}
};
ws.onerror=function(){
if(!solved){
dot.className='dot off';
}
};
function tap(cx,cy){
var ir=img.getBoundingClientRect();
if(!ir.width || !ir.height){
return;
}
var nx=(cx-ir.left)/ir.width;
var ny=(cy-ir.top)/ir.height;
if(nx<0||nx>1||ny<0||ny>1){
return;
}
var wr=wrap.getBoundingClientRect();
ring.style.left=(cx-wr.left)+'px';
ring.style.top=(cy-wr.top)+'px';
ring.classList.remove('go');
void ring.offsetWidth;
ring.classList.add('go');
if(ws.readyState===1){
ws.send(JSON.stringify({
nx:nx,
ny:ny
}));
}
}
var dragging=false;
function pointerPos(ev){
var p=ev.touches ? ev.touches[0] : ev;
return {x:p.clientX,y:p.clientY};
}
function sendPointer(type,ev){
var p=pointerPos(ev);
var ir=img.getBoundingClientRect();
if(!ir.width || !ir.height) return;
var nx=(p.x-ir.left)/ir.width;
var ny=(p.y-ir.top)/ir.height;
if(nx<0||nx>1||ny<0||ny>1) return;
if(ws.readyState===1){
ws.send(JSON.stringify({
type:type,
nx:nx,
ny:ny
}));
}
}
img.addEventListener('pointerdown',function(ev){
ev.preventDefault();
dragging=true;
try{img.setPointerCapture(ev.pointerId);}catch(_){}
sendPointer('down',ev);
});
img.addEventListener('pointermove',function(ev){
if(!dragging) return;
ev.preventDefault();
sendPointer('move',ev);
});
img.addEventListener('pointerup',function(ev){
if(!dragging) return;
ev.preventDefault();
dragging=false;
sendPointer('up',ev);
});
img.addEventListener('pointercancel',function(ev){
if(!dragging) return;
dragging=false;
sendPointer('up',ev);
});
</script>
</body>
</html>`;
}
/**
* CAPTCHA clip expression.
*
* Finds visible reCAPTCHA/hCaptcha/Turnstile elements.
*/
const CAPTCHA_CLIP_EXPR = `(function(){
function box(el){
if(!el) return null;
var r=el.getBoundingClientRect();
if(r.width<8||r.height<8) return null;
var st=getComputedStyle(el);
if(
st.visibility==='hidden' ||
st.display==='none' ||
parseFloat(st.opacity||'1')<0.05
){
return null;
}
if(
r.bottom<0 ||
r.right<0 ||
r.top>innerHeight ||
r.left>innerWidth
){
return null;
}
return {
x:r.left,
y:r.top,
w:r.width,
h:r.height
};
}
var sel=[
'iframe[src*="recaptcha/api2/anchor"]',
'iframe[src*="recaptcha/api2/bframe"]',
'iframe[src*="recaptcha/enterprise/anchor"]',
'iframe[src*="recaptcha/enterprise/bframe"]',
'iframe[title="reCAPTCHA"]',
'.g-recaptcha',
'[data-sitekey]',
'iframe[src*="hcaptcha.com"]',
'iframe[src*="newassets.hcaptcha.com"]',
'.h-captcha',
'.cf-turnstile',
'iframe[src*="challenges.cloudflare.com"]'
];
var rects=[];
sel.forEach(function(s){
document
.querySelectorAll(s)
.forEach(function(e){
var b=box(e);
if(b){
rects.push(b);
}
var p=e.parentElement;
if(p){
var pb=box(p);
if(
pb &&
pb.w<innerWidth*0.95
){
rects.push(pb);
}
}
});
});
if(!rects.length){
return null;
}
var x0=Math.min.apply(
null,
rects.map(function(r){
return r.x;
})
);
var y0=Math.min.apply(
null,
rects.map(function(r){
return r.y;
})
);
var x1=Math.max.apply(
null,
rects.map(function(r){
return r.x+r.w;
})
);
var y1=Math.max.apply(
null,
rects.map(function(r){
return r.y+r.h;
})
);
var pad=10;
x0=Math.max(0,x0-pad);
y0=Math.max(0,y0-pad);
x1=Math.min(
innerWidth,
x1+pad
);
y1=Math.min(
innerHeight,
y1+pad
);
return {
x:Math.round(x0),
y:Math.round(y0),
width:Math.round(x1-x0),
height:Math.round(y1-y0)
};
})()`;
// ---------------------------------------------------------------------------
// Public tunnels
// ---------------------------------------------------------------------------
/**
* Download a URL to a file.
*/
async function downloadFile(url, dest) {
const res = await fetch(url, {
redirect: 'follow',
});
if (!res.ok) {
throw new Error(
`download HTTP ${res.status}`
);
}
const { Readable } =
require('stream');
await new Promise((resolve, reject) => {
const out =
fs.createWriteStream(dest);
Readable
.fromWeb(res.body)
.pipe(out)
.on('finish', resolve)
.on('error', reject);
});
}
/**
* Wait until public URL becomes reachable.
*/
async function waitReachable(
url,
timeoutMs,
initialDelayMs = 9000
) {
await new Promise((resolve) =>
setTimeout(resolve, initialDelayMs)
);
const deadline =
Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const r = await fetch(url, {
method: 'GET',
redirect: 'manual',
});
if (r.status) {
return true;
}
} catch {}
await new Promise((resolve) =>
setTimeout(resolve, 1500)
);
}
return false;
}
/**
* Ensure cloudflared exists.
*/
async function ensureCloudflared(log) {
const plat = process.platform;
const arch = process.arch;
let asset;
let binName;
if (plat === 'linux') {
asset =
arch === 'arm64'
? 'cloudflared-linux-arm64'
: 'cloudflared-linux-amd64';
binName = 'cloudflared';
} else if (plat === 'win32') {
asset =
'cloudflared-windows-amd64.exe';
binName = 'cloudflared.exe';
} else {
return null;
}
const cacheDir = path.join(
os.homedir(),
'.cache',
'i-am-not-a-robot'
);
const bin = path.join(
cacheDir,
binName
);
try {
fs.accessSync(
bin,
fs.constants.X_OK
);
return bin;
} catch {}
try {
fs.mkdirSync(cacheDir, {
recursive: true,
});
const url =
`https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`;
log(
'external',
'fetching cloudflared binary (one-time)',
{ asset }
);
await downloadFile(url, bin);
if (plat !== 'win32') {
fs.chmodSync(bin, 0o755);
}
return bin;
} catch (e) {
log(
'warn',
'cloudflared fetch failed — will use ssh tunnel',
e && e.message
);
return null;
}
}
const TUNNEL_PROVIDERS = [
{
name: 'localhost.run',
args: (port) => [
'-o',
'StrictHostKeyChecking=accept-new',
'-o',
'ServerAliveInterval=30',
'-o',
'ExitOnForwardFailure=yes',
'-o',
'ConnectTimeout=15',
'-R',
`80:localhost:${port}`,
'nokey@localhost.run',
],
re:
/https:\/\/[a-z0-9-]+\.lhr\.life/i,
},
{
name: 'pinggy',
args: (port) => [
'-o',
'StrictHostKeyChecking=accept-new',
'-o',
'ServerAliveInterval=30',
'-o',
'ExitOnForwardFailure=yes',
'-o',
'ConnectTimeout=15',
'-p',
'443',
'-R0',
`localhost:${port}`,
'a.pinggy.io',
],
re:
/https:\/\/[a-z0-9-]+\.[a-z0-9.-]*pinggy[a-z-]*\.(?:link|online)/i,
},
];
class Tunnel {
constructor(log) {
this.log = log || (() => {});
this.proc = null;
this.url = null;
this.provider = null;
}
async open(
port,
{ timeoutMs = 20000 } = {}
) {
try {
const url =
await this._openCloudflared(port);
if (url) {
this.url = url;
this.provider =
'cloudflared';
this.log(
'state',
'tunnel up',
{
provider:
'cloudflared',
url,
}
);
return url;
}
} catch (e) {
this.log(
'warn',
'cloudflared failed — trying ssh',
e && e.message
);
}
this.close();
for (const provider of TUNNEL_PROVIDERS) {
this.log(
'external',
'opening tunnel',
{
provider:
provider.name,
port,
}
);
const url =
await this
._trySsh(
provider,
port,
timeoutMs
)
.catch((e) => {
this.log(
'warn',
'tunnel provider failed',
{
provider:
provider.name,
err:
e &&
e.message,
}
);
return null;
});
if (url) {
this.url = url;
this.provider =
provider.name;
this.log(
'state',
'tunnel up',
{
provider:
provider.name,
url,
}
);
return url;
}
this.close();
}
this.log(
'warn',
'all tunnels failed — relay will be LAN-only'
);
return null;
}
async _openCloudflared(port) {
const bin =
await ensureCloudflared(
this.log
);
if (!bin) {
return null;
}
return new Promise(
(resolve, reject) => {
const proc = spawn(
bin,
[
'tunnel',
'--url',
`http://localhost:${port}`,
'--no-autoupdate',
]
);
this.proc = proc;
let buf = '';
let settled = false;
const finish = (
fn,
arg
) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn(arg);
};
const onData = (d) => {
buf += d.toString();
if (buf.length > 65536) {
buf = buf.slice(-4096);
}
const m =
buf.match(
/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
);
if (!m) return;
proc.stdout.removeListener(
'data',
onData
);
proc.stderr.removeListener(
'data',
onData
);
this.log(
'external',
'cloudflared url up — waiting for DNS',
{ url: m[0] }
);
waitReachable(
m[0],
20000
).then((ok) => {
finish(
ok ? resolve : reject,
ok
? m[0]
: new Error(
'cloudflared url not reachable'
)
);
});
};
proc.stdout.on(
'data',
onData
);
proc.stderr.on(
'data',
onData
);
proc.on(
'error',
(e) =>
finish(reject, e)
);
proc.on(
'exit',
(code) =>
finish(
reject,
new Error(
`cloudflared exited (${code})`
)
)
);
const timer = setTimeout(() => {
try {
proc.kill(
'SIGKILL'
);
} catch {}
finish(
reject,
new Error(
'cloudflared timeout'
)
);
}, 50000);
}
);
}
_trySsh(
provider,
port,
timeoutMs
) {
return new Promise(
(resolve, reject) => {
const proc = spawn(
'ssh',
provider.args(port)
);
this.proc = proc;
let buf = '';
let settled = false;
const finish = (
fn,
arg
) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn(arg);
};
const onData = (d) => {
buf += d.toString();
if (buf.length > 65536) {
buf = buf.slice(-4096);
}
const m =
buf.match(provider.re);
if (!m) return;
proc.stdout.removeListener(
'data',
onData
);
proc.stderr.removeListener(
'data',
onData
);
finish(
resolve,
m[0]
);
};
proc.stdout.on(
'data',
onData
);
proc.stderr.on(
'data',
onData
);
proc.on(
'error',
(e) =>
finish(reject, e)
);
proc.on(
'exit',
(code) =>
finish(
reject,
new Error(
`ssh exited (${code}) before a URL`
)
)
);
const timer = setTimeout(() => {
try {
proc.kill(
'SIGKILL'
);
} catch {}
finish(
reject,
new Error(
'tunnel timeout'
)
);
}, timeoutMs);
}
);
}
close() {
try {
if (this.proc) {
this.proc.kill(
'SIGKILL'
);
}
} catch {}
this.proc = null;
}
}
/**
* One live relay session.
*/
class HumanRelay {
constructor(opts = {}) {
this.log = makeLogger(
opts.log !== false
);
this.cdp = null;
this.server = null;
this.wss = null;
this.wsClients = new Set();
this.lastFrame = null;
this.clip = null;
this._capTimer = null;
this._captureBusy = false;
this.vp = {
w: 360,
h: 640,
};
this.token = null;
this.relayUrl = null;
this.relayUrls = null;
this.target = null;
this.tunnel = null;
this._killTimer = null;
/*
* JavaScript dialog state.
*/
this._dialogWatchdog = null;
this._dialogOpen = false;
this._dialogInfo = null;
/*
* Shutdown state.
*/
this._stopping = false;
/*
* Prevent overlapping solve polls.
*/
this._solvePollBusy = false;
}
/**
* Set dialog state.
*/
_setDialogState(
open,
info = null
) {
this._dialogOpen = !!open;
this._dialogInfo =
info || null;
if (open) {
this.log(
'state',
'javascript dialog is blocking page',
info
);
} else {
this.log(
'state',
'javascript dialog closed'
);
}
}
/**
* Try to accept an active JavaScript dialog.
*
* "No dialog is showing" is intentionally treated as normal.
*/
async _acceptJavaScriptDialog(
source = 'unknown'
) {
if (
!this.cdp ||
this.cdp.closed
) {
return false;
}
try {
await this.cdp.send(
'Page.handleJavaScriptDialog',
{
accept: true,
},
{
timeoutMs: 1200,
}
);
this.log(
'state',
'javascript dialog accepted',
{ source }
);
this._setDialogState(false);
return true;
} catch (e) {
const message =
e && e.message
? e.message
: String(e);
if (
/No dialog is showing/i.test(
message
)
) {
return false;
}
this.log(
'warn',
'failed to accept javascript dialog',
{
source,
error: message,
}
);
return false;
}
}
/**
* Short watchdog for a dialog that may already exist
* or appear immediately around CDP attachment.
*/
_startDialogWatchdog() {
this._stopDialogWatchdog();
let attempts = 0;
this._dialogWatchdog =
setInterval(
async () => {
if (
!this.cdp ||
this.cdp.closed
) {
return;
}
if (!this._dialogOpen) {
return;
}
attempts += 1;
const accepted =
await this._acceptJavaScriptDialog(
'watchdog'
);
if (
accepted ||
attempts >= 20
) {
this._stopDialogWatchdog();
}
},
250
);
if (
this._dialogWatchdog.unref
) {
this._dialogWatchdog.unref();
}
}
_stopDialogWatchdog() {
if (!this._dialogWatchdog) {
return;
}
clearInterval(
this._dialogWatchdog
);
this._dialogWatchdog = null;
}
/**
* Connect via CDP, start screencast/capture and serve phone relay.
*/
async start({
cdpUrl,
targetUrl,
host,
port,
tunnel,
maxLifetimeMs,
} = {}) {
this._stopping = false;
this.log(
'entry',
'start_human_relay',
{
cdpUrl:
redactUrl(cdpUrl),
targetUrl,
}
);
if (!cdpUrl) {
throw new Error(
'cdpUrl is required, e.g. http://localhost:9222'
);
}
if (
/^wss?:\/\//i.test(cdpUrl)
) {
throw new Error(
'pass the CDP HTTP endpoint (http://host:port), not a ws:// url'
);
}
const targets =
await fetchTargets(cdpUrl);
const pages =
targets.filter(
(t) =>
t.type === 'page' &&
t.webSocketDebuggerUrl
);
this.log(
'state',
'targets fetched',
{
total:
targets.length,
pages:
pages.length,
}
);
let target =
targetUrl
? pages.find((p) =>
(p.url || '')
.includes(targetUrl)
)
: null;
if (!target) {
target =
pages.find(
(p) =>
p.url &&
!/^(about:|chrome:|devtools:)/.test(
p.url
)
) ||
pages[0];
}
if (!target) {
throw new Error(
'no inspectable page target found at cdpUrl'
);
}
if (
!sameCdpHost(
cdpUrl,
target.webSocketDebuggerUrl
)
) {
throw new Error(
'CDP debugger ws host does not match cdpUrl host (refusing to dial elsewhere)'
);
}
this.target = target;
this.log(
'state',
'target picked',
{
targetId:
target.id,
type:
target.type,
title:
target.title,
url:
redactUrl(target.url),
}
);
this.cdp =
new CdpSession(
target.webSocketDebuggerUrl,
this.log,
{
defaultTimeoutMs: 5000,
}
);
await this.cdp.connect();
/*
* Register Page event handlers BEFORE Page.enable.
*
* This minimizes the race window around javascript dialogs.
*/
this.cdp.on(
'Page.javascriptDialogOpening',
async (params = {}) => {
const info = {
type:
params.type || null,
message:
params.message || '',
url:
redactUrl(
params.url || ''
),
hasBrowserHandler:
params.hasBrowserHandler !== false,
};
this._setDialogState(
true,
info
);
this.log(
'state',
'javascript dialog opening',
info
);
/*
* Accept immediately.
*/
await this._acceptJavaScriptDialog(
'javascriptDialogOpening'
);
/*
* Keep a short watchdog in case
* the native dialog appears just after
* the event.
*/
this._startDialogWatchdog();
}
);
this.cdp.on(
'Page.javascriptDialogClosed',
(params = {}) => {
this._setDialogState(
false,
{
accepted:
params.result === true,
hasBrowserHandler:
params.hasBrowserHandler !== false,
}
);
this._stopDialogWatchdog();
}
);
await this.cdp.send(
'Page.enable'
);
/*
* A dialog may already exist before CDP attached.
*
* Try several short times.
* "No dialog is showing" is normal.
*/
for (let i = 0; i < 5; i += 1) {
const accepted =
await this._acceptJavaScriptDialog(
`initial-${i + 1}`
);
if (accepted) {
break;
}
await new Promise(
(resolve) =>
setTimeout(
resolve,
150
)
);
}
await this.cdp.send(
'Runtime.enable'
);
/*
* Background tabs may not composite.
*/
try {
await this.cdp.send(
'Page.bringToFront',
{},
{
timeoutMs: 3000,
}
);
} catch {}
/*
* Detect viewport.
*/
try {
const r =
await this.cdp.send(
'Runtime.evaluate',
{
expression:
'({w:window.innerWidth,h:window.innerHeight})',
returnByValue:
true,
},
{
timeoutMs: 3000,
}
);
if (
r &&
r.result &&
r.result.value
) {
this.vp =
r.result.value;
}
} catch (e) {
this.log(
'warn',
'viewport eval failed, using default',
e && e.message
);
}
this.log(
'state',
'viewport',
this.vp
);
/*
* Start CAPTCHA capture.
*/
this._startCapture();
this.log(
'state',
'captcha capture started'
);
this.token =
crypto.randomBytes(12)
.toString('hex');
await this._startHttp({
host,
port,
});
/*
* Public tunnel.
*/
const tunnelMode =
tunnel !== undefined
? tunnel
: (
process.env
.IAMNOTAROBOT_TUNNEL ===
'off'
? 'off'
: 'auto'
);
if (
tunnelMode !== 'off' &&
!host
) {
this.tunnel =
new Tunnel(
this.log
);
const localPort =
this.server
.address()
.port;
const pub =
await this.tunnel
.open(localPort)
.catch((e) => {
this.log(
'warn',
'tunnel open failed',
e && e.message
);
return null;
});
if (pub) {
const publicUrl =
`${pub.replace(/\/+$/, '')}/r/${this.token}/`;
this.relayUrls = [
publicUrl,
...(this.relayUrls || []),
];
this.relayUrl =
publicUrl;
} else {
this.tunnel = null;
}
}
const maxMs =
Number.isFinite(
maxLifetimeMs
)
? maxLifetimeMs
: 15 * 60 * 1000;
this._killTimer =
setTimeout(
() => {
this.log(
'warn',
'relay hit max lifetime — auto-stopping'
);
this.stop();
},
maxMs
);
if (
this._killTimer.unref
) {
this._killTimer.unref();
}
this.log(
'exit',
'relay live',
{
relayUrl:
this.relayUrl,
tunnel:
this.tunnel
? this.tunnel.provider
: 'none',
}
);
return {
relayUrl:
this.relayUrl,
relayUrls:
this.relayUrls,
pageUrl:
target.url,
viewport:
this.vp,
};
}
_startHttp({
host,
port,
} = {}) {
return new Promise(
(resolve, reject) => {
const prefix =
`/r/${this.token}`;
const tokenBuf =
Buffer.from(
this.token
);
const checkToken =
(reqPath) => {
const parts =
reqPath.split('/');
const given =
parts[2] || '';
const ok =
parts[1] === 'r' &&
given.length ===
this.token.length &&
crypto.timingSafeEqual(
Buffer.from(given),
tokenBuf
);
return ok
? parts
: null;
};
const server =
http.createServer(
(req, res) => {
const reqPath =
(req.url || '')
.split('?')[0];
const parts =
checkToken(reqPath);
if (!parts) {
this.log(
'warn',
'rejected request with bad/expired token'
);
res
.writeHead(404)
.end('not found');
return;
}
const sub =
'/' +
parts
.slice(3)
.join('/');
if (
sub === '/' &&
(req.method || 'GET') ===
'GET'
) {
this.log(
'debug',
'phone opened relay page'
);
res.writeHead(
200,
{
'Content-Type':
'text/html; charset=utf-8',
'Content-Security-Policy':
"default-src 'none'; img-src blob: data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self' ws: wss:",
'X-Content-Type-Options':
'nosniff',
'Referrer-Policy':
'no-referrer',
}
);
res.end(
renderPhonePage(
'i-am-not-a-robot · solve the CAPTCHA'
)
);
return;
}
res
.writeHead(404)
.end('not found');
}
);
/*
* WebSocket transport:
* JPEG frames down,
* normalized taps up.
*/
this.wss =
new WebSocketServer({
noServer: true,
});
server.on(
'upgrade',
(
req,
socket,
head
) => {
const reqPath =
(req.url || '')
.split('?')[0];
const parts =
checkToken(reqPath);
if (
!parts ||
parts[3] !== 'ws'
) {
socket.destroy();
return;
}
this.wss.handleUpgrade(
req,
socket,
head,
(ws) => {
this.wsClients.add(
ws
);
this.log(
'state',
'phone WS connected',
{
clients:
this.wsClients.size,
}
);
if (this.lastFrame) {
try {
ws.send(
Buffer.from(
this.lastFrame,
'base64'
)
);
} catch {}
}
ws.on(
'message',
(data) => {
try {
const {
nx,
ny,
} =
JSON.parse(
data.toString()
);
this._tap(
nx,
ny
).catch((e) => {
this.log(
'warn',
'tap relay failed',
e && e.message
);
});
} catch (e) {
this.log(
'error',
'bad WS tap message',
e && e.message
);
}
}
);
ws.on(
'close',
() => {
this.wsClients.delete(
ws
);
this.log(
'state',
'phone WS left',
{
clients:
this.wsClients.size,
}
);
}
);
ws.on(
'error',
() => {
this.wsClients.delete(
ws
);
}
);
}
);
}
);
server.on(
'error',
reject
);
server.listen(
port || 0,
() => {
const p =
server
.address()
.port;
this.server =
server;
const ips =
listIpv4();
this.relayUrls =
ips.map(
(ip) =>
`http://${ip}:${p}${prefix}/`
);
this.relayUrl =
host
? `http://${host}:${p}${prefix}/`
: this.relayUrls[0];
this.log(
'state',
'relay http+ws listening',
{
primary:
this.relayUrl,
candidates:
this.relayUrls,
}
);
resolve();
}
);
}
);
}
/**
* Capture loop.
*
* Important:
* there is no overlapping async capture.
*/
_startCapture() {
this._lastClip = null;
this._missCount = 0;
this._captureBusy = false;
const tick = async () => {
if (this._stopping) return;
if (
!this.cdp ||
this.cdp.closed
) {
return;
}
/*
* A JS dialog blocks the page.
* Don't stack Runtime.evaluate calls behind it.
*/
if (this._dialogOpen) {
return;
}
if (this._captureBusy) {
return;
}
if (
this.wsClients.size === 0 &&
this.lastFrame
) {
return;
}
this._captureBusy = true;
try {
let clip =
await this._captchaClip();
/*
* Hold the last valid CAPTCHA box through
* short detection gaps.
*/
if (clip) {
this._lastClip = clip;
this._missCount = 0;
} else if (
this._lastClip &&
this._missCount < 6
) {
this._missCount++;
clip = this._lastClip;
} else {
this._missCount++;
}
this.clip = clip;
const params = {
format: 'jpeg',
quality: 80,
};
if (clip) {
params.clip = {
x: clip.x,
y: clip.y,
width: clip.width,
height: clip.height,
scale: 1,
};
}
const shot =
await this.cdp.send(
'Page.captureScreenshot',
params,
{
timeoutMs: 4000,
}
);
if (
!shot ||
!shot.data
) {
return;
}
this.lastFrame =
shot.data;
const bin =
Buffer.from(
shot.data,
'base64'
);
for (
const ws of this.wsClients
) {
if (
ws.readyState !==
WebSocket.OPEN
) {
continue;
}
try {
ws.send(bin);
} catch {}
}
} catch (e) {
const message =
e && e.message
? e.message
: String(e);
if (
!/No dialog is showing/i.test(
message
)
) {
this.log(
'debug',
'capture tick failed',
message
);
}
} finally {
this._captureBusy = false;
}
};
this._capTimer =
setInterval(
tick,
90
);
if (
this._capTimer.unref
) {
this._capTimer.unref();
}
/*
* First frame immediately.
*/
tick();
}
/**
* Visible CAPTCHA bounding box.
*/
async _captchaClip() {
if (
!this.cdp ||
this.cdp.closed
) {
return null;
}
if (this._dialogOpen) {
return null;
}
try {
const r =
await this.cdp.send(
'Runtime.evaluate',
{
expression:
CAPTCHA_CLIP_EXPR,
returnByValue:
true,
},
{
timeoutMs: 3000,
}
);
return (
r &&
r.result &&
r.result.value
? r.result.value
: null
);
} catch {
return null;
}
}
/**
* Forward human tap from normalized phone coordinates
* into the real Chrome page.
*/
async _mouseDown(nx, ny) {
nx = Number(nx);
ny = Number(ny);
if(!Number.isFinite(nx) || !Number.isFinite(ny) ||
nx < 0 || nx > 1 || ny < 0 || ny > 1) return;
if(!this.cdp || this.cdp.closed) return;
const clip = this.clip || {
x:0,
y:0,
width:this.vp.w,
height:this.vp.h,
};
const x = Math.round(clip.x + nx * clip.width);
const y = Math.round(clip.y + ny * clip.height);
this._dragging = true;
this.log('state','drag start -> page',{x,y});
await this.cdp.send(
'Input.dispatchMouseEvent',
{
type:'mouseMoved',
x,
y,
buttons:0,
},
{timeoutMs:3000}
);
await this.cdp.send(
'Input.dispatchMouseEvent',
{
type:'mousePressed',
x,
y,
button:'left',
buttons:1,
clickCount:1,
},
{timeoutMs:3000}
);
}
async _mouseMove(nx, ny) {
nx = Number(nx);
ny = Number(ny);
if(!this._dragging) return;
if(!Number.isFinite(nx) || !Number.isFinite(ny) ||
nx < 0 || nx > 1 || ny < 0 || ny > 1) return;
if(!this.cdp || this.cdp.closed) return;
const clip = this.clip || {
x:0,
y:0,
width:this.vp.w,
height:this.vp.h,
};
const x = Math.round(clip.x + nx * clip.width);
const y = Math.round(clip.y + ny * clip.height);
await this.cdp.send(
'Input.dispatchMouseEvent',
{
type:'mouseMoved',
x,
y,
buttons:1,
},
{timeoutMs:3000}
);
}
async _mouseUp(nx, ny) {
nx = Number(nx);
ny = Number(ny);
if(!this._dragging) return;
this._dragging = false;
if(!Number.isFinite(nx) || !Number.isFinite(ny) ||
nx < 0 || nx > 1 || ny < 0 || ny > 1) return;
if(!this.cdp || this.cdp.closed) return;
const clip = this.clip || {
x:0,
y:0,
width:this.vp.w,
height:this.vp.h,
};
const x = Math.round(clip.x + nx * clip.width);
const y = Math.round(clip.y + ny * clip.height);
this.log('state','drag end -> page',{x,y});
await this.cdp.send(
'Input.dispatchMouseEvent',
{
type:'mouseReleased',
x,
y,
button:'left',
buttons:0,
clickCount:1,
},
{timeoutMs:3000}
);
}
async _tap(nx, ny) {
nx = Number(nx);
ny = Number(ny);
if (
!Number.isFinite(nx) ||
!Number.isFinite(ny) ||
nx < 0 ||
nx > 1 ||
ny < 0 ||
ny > 1
) {
this.log(
'warn',
'tap ignored: coords out of [0,1]',
{
nx,
ny,
}
);
return;
}
if (
!this.cdp ||
this.cdp.closed
) {
this.log(
'warn',
'tap ignored: CDP unavailable'
);
return;
}
/*
* If the browser is blocked by a JavaScript dialog,
* accept it first and don't send the same tap
* through to the page.
*/
if (this._dialogOpen) {
this.log(
'state',
'tap received while javascript dialog is open — accepting dialog first'
);
await this._acceptJavaScriptDialog(
'tap'
);
return;
}
/*
* Map normalized coordinates from the cropped image
* back into the full browser viewport.
*/
const clip =
this.clip || {
x: 0,
y: 0,
width:
this.vp.w,
height:
this.vp.h,
};
const x =
Math.round(
clip.x +
nx * clip.width
);
const y =
Math.round(
clip.y +
ny * clip.height
);
this.log(
'state',
'tap relayed -> page',
{
x,
y,
}
);
await this.cdp.send(
'Input.dispatchMouseEvent',
{
type:
'mouseMoved',
x,
y,
},
{
timeoutMs: 3000,
}
);
await this.cdp.send(
'Input.dispatchMouseEvent',
{
type:
'mousePressed',
x,
y,
button:
'left',
clickCount:
1,
},
{
timeoutMs: 3000,
}
);
await this.cdp.send(
'Input.dispatchMouseEvent',
{
type:
'mouseReleased',
x,
y,
button:
'left',
clickCount:
1,
},
{
timeoutMs: 3000,
}
);
}
/**
* Poll until CAPTCHA token appears.
*
* CAPTCHA is solved by the human.
*/
async awaitSolve({
timeoutMs = 300000,
pollMs = 1500,
} = {}) {
this.log(
'entry',
'await_human_solve',
{
timeoutMs,
pollMs,
}
);
if (!this.cdp) {
throw new Error(
'no active relay; call start_human_relay first'
);
}
const expr =
'(function(){try{' +
'function v(s){var e=document.querySelector(s);return e&&e.value?e.value.length:0;}' +
'var n=0;' +
"n+=v('textarea[id^=\"g-recaptcha-response\"]');" +
"n+=v('textarea[name=\"h-captcha-response\"]');" +
"n+=v('input[name=\"cf-turnstile-response\"]');" +
"if(!n&&typeof grecaptcha!=='undefined'&&grecaptcha.getResponse){try{n+=(grecaptcha.getResponse()||'').length;}catch(e){}}" +
"if(!n&&typeof hcaptcha!=='undefined'&&hcaptcha.getResponse){try{n+=(hcaptcha.getResponse()||'').length;}catch(e){}}" +
'return n>0;}catch(e){return false;}})()';
const deadline =
Date.now() + timeoutMs;
let polls = 0;
while (
Date.now() < deadline
) {
if (
this.cdp.closed
) {
this.log(
'error',
'CDP connection dropped — stopping poll'
);
return {
passed:
false,
reason:
'cdp_closed',
};
}
/*
* Do not send Runtime.evaluate while a
* native JS dialog blocks the page.
*/
if (this._dialogOpen) {
await this._acceptJavaScriptDialog(
'awaitSolve'
);
await new Promise(
(resolve) =>
setTimeout(
resolve,
250
)
);
continue;
}
if (this._solvePollBusy) {
await new Promise(
(resolve) =>
setTimeout(
resolve,
pollMs
)
);
continue;
}
this._solvePollBusy = true;
try {
const r =
await this.cdp.send(
'Runtime.evaluate',
{
expression:
expr,
returnByValue:
true,
},
{
timeoutMs: 4000,
}
);
polls++;
if (
r &&
r.result &&
r.result.value === true
) {
this.log(
'exit',
'solve detected — passed',
{
polls,
}
);
/*
* Tell phone before relay teardown.
*/
for (
const ws of this.wsClients
) {
if (
ws.readyState !==
WebSocket.OPEN
) {
continue;
}
try {
ws.send(
JSON.stringify({
type:
'solved',
})
);
} catch {}
}
await new Promise(
(resolve) =>
setTimeout(
resolve,
250
)
);
return {
passed:
true,
};
}
} catch (e) {
this.log(
'warn',
'poll eval failed',
e && e.message
);
} finally {
this._solvePollBusy = false;
}
await new Promise(
(resolve) =>
setTimeout(
resolve,
pollMs
)
);
}
this.log(
'exit',
'timed out waiting for human — not passed',
{
polls,
}
);
return {
passed:
false,
reason:
'timeout',
};
}
/**
* Full shutdown.
*/
async stop() {
if (this._stopping) {
return;
}
this._stopping = true;
this.log(
'entry',
'stop relay'
);
if (this._killTimer) {
clearTimeout(
this._killTimer
);
this._killTimer = null;
}
if (this._capTimer) {
clearInterval(
this._capTimer
);
this._capTimer = null;
}
this._stopDialogWatchdog();
this._captureBusy = false;
this._solvePollBusy = false;
try {
if (this.tunnel) {
this.tunnel.close();
}
} catch {}
this.tunnel = null;
for (
const ws of this.wsClients
) {
try {
ws.close();
} catch {}
}
this.wsClients.clear();
try {
if (this.wss) {
this.wss.close();
}
} catch {}
try {
if (this.server) {
await new Promise(
(resolve) => {
try {
this.server.close(
() => resolve()
);
} catch {
resolve();
}
}
);
}
} catch {}
try {
if (this.cdp) {
await this.cdp.close();
}
} catch {}
this.cdp = null;
this.server = null;
this.wss = null;
this._dialogOpen = false;
this._dialogInfo = null;
this.lastFrame = null;
this.clip = null;
this._lastClip = null;
this.log(
'state',
'relay stopped'
);
this._stopping = false;
}
}
module.exports = {
HumanRelay,
CdpSession,
Tunnel,
detectLanIp,
listIpv4,
fetchTargets,
ensureCloudflared,
};