human-input-kit 0.2.1__py3-none-any.whl
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.
- examples/demo_warmup.py +51 -0
- examples/mlx_warmup_demo.py +200 -0
- human_input_kit/__init__.py +23 -0
- human_input_kit/async_.py +19 -0
- human_input_kit/bezier.py +68 -0
- human_input_kit/cli.py +100 -0
- human_input_kit/deal.py +25 -0
- human_input_kit/gesture_schema.py +90 -0
- human_input_kit/gestures.py +84 -0
- human_input_kit/idle.py +41 -0
- human_input_kit/keyboard.py +31 -0
- human_input_kit/record.py +90 -0
- human_input_kit/replay.py +57 -0
- human_input_kit/rng.py +16 -0
- human_input_kit/scroll.py +28 -0
- human_input_kit/sync_.py +143 -0
- human_input_kit-0.2.1.dist-info/METADATA +272 -0
- human_input_kit-0.2.1.dist-info/RECORD +23 -0
- human_input_kit-0.2.1.dist-info/WHEEL +4 -0
- human_input_kit-0.2.1.dist-info/entry_points.txt +2 -0
- human_input_kit-0.2.1.dist-info/licenses/LICENSE +21 -0
- recipes/scroll_news.py +63 -0
- recipes/youtube_idle.py +69 -0
examples/demo_warmup.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Example warmup script using human-input-kit with Playwright (async API).
|
|
3
|
+
|
|
4
|
+
Run from the package root::
|
|
5
|
+
|
|
6
|
+
python examples/demo_warmup.py
|
|
7
|
+
python examples/demo_warmup.py --seed 42 --url https://news.ycombinator.com
|
|
8
|
+
|
|
9
|
+
Or after install::
|
|
10
|
+
|
|
11
|
+
python -m examples.demo_warmup # if on PYTHONPATH
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
|
|
19
|
+
from human_input_kit import seed_rng
|
|
20
|
+
from human_input_kit.async_ import bezier_mouse_path, idle_jitter, move_mouse_along, random_scroll
|
|
21
|
+
from playwright.async_api import async_playwright
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def warmup(*, url: str, seed: int, headless: bool) -> None:
|
|
25
|
+
seed_rng(seed)
|
|
26
|
+
async with async_playwright() as playwright:
|
|
27
|
+
browser = await playwright.chromium.launch(headless=headless)
|
|
28
|
+
page = await browser.new_page(viewport={"width": 1280, "height": 720})
|
|
29
|
+
await page.goto(url, wait_until="domcontentloaded")
|
|
30
|
+
|
|
31
|
+
path = bezier_mouse_path((80, 120), (640, 360), steps=20)
|
|
32
|
+
await move_mouse_along(page, path)
|
|
33
|
+
await random_scroll(page, bursts=4)
|
|
34
|
+
await idle_jitter(page, moves=3)
|
|
35
|
+
|
|
36
|
+
title = await page.title()
|
|
37
|
+
print(f"Warmup complete (seed={seed}): {title}")
|
|
38
|
+
await browser.close()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main() -> None:
|
|
42
|
+
parser = argparse.ArgumentParser(description="Human-input-kit warmup demo")
|
|
43
|
+
parser.add_argument("--url", default="https://news.ycombinator.com")
|
|
44
|
+
parser.add_argument("--seed", type=int, default=42, help="RNG seed for reproducible motion")
|
|
45
|
+
parser.add_argument("--headed", action="store_true", help="Run browser with UI")
|
|
46
|
+
args = parser.parse_args()
|
|
47
|
+
asyncio.run(warmup(url=args.url, seed=args.seed, headless=not args.headed))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
main()
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MLX profile warmup demo — pseudocode + optional cdp-connect-kit peer integration.
|
|
3
|
+
|
|
4
|
+
This script is **documentation-first**: it shows how to run human-input-kit warmup
|
|
5
|
+
gestures against a Multilogin X profile started via the local Launcher API.
|
|
6
|
+
|
|
7
|
+
Peer dependencies (install separately — NOT required by human-input-kit):
|
|
8
|
+
|
|
9
|
+
pip install human-input-kit
|
|
10
|
+
pip install cdp-connect-kit[mlx] # Launcher start/stop + CDP helpers
|
|
11
|
+
playwright install chromium
|
|
12
|
+
|
|
13
|
+
Environment:
|
|
14
|
+
|
|
15
|
+
export MLX_TOKEN=your_bearer_token
|
|
16
|
+
export MLX_FOLDER_ID=your-folder-uuid
|
|
17
|
+
|
|
18
|
+
Run with an existing CDP endpoint (skip Launcher TODOs):
|
|
19
|
+
|
|
20
|
+
CDP_URL=http://127.0.0.1:55513 python examples/mlx_warmup_demo.py --profile-id UUID
|
|
21
|
+
|
|
22
|
+
Or after wiring Launcher start below:
|
|
23
|
+
|
|
24
|
+
python examples/mlx_warmup_demo.py --profile-id UUID --url https://example.com
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import asyncio
|
|
31
|
+
import os
|
|
32
|
+
from typing import TYPE_CHECKING
|
|
33
|
+
|
|
34
|
+
from human_input_kit import seed_rng
|
|
35
|
+
from human_input_kit.async_ import bezier_mouse_path, idle_jitter, move_mouse_along, random_scroll
|
|
36
|
+
from playwright.async_api import async_playwright
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from cdp_connect_kit.mlx import MlxLauncher, MlxSession
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_cdp_connect_kit() -> tuple[type[MlxLauncher], type[MlxSession]]:
|
|
43
|
+
try:
|
|
44
|
+
from cdp_connect_kit.mlx import MlxLauncher, MlxSession
|
|
45
|
+
except ImportError as exc:
|
|
46
|
+
msg = (
|
|
47
|
+
"cdp-connect-kit[mlx] is a peer dependency for Launcher integration.\n"
|
|
48
|
+
"Install with: pip install cdp-connect-kit[mlx]\n"
|
|
49
|
+
"Or set CDP_URL to an already-running profile and skip Launcher start."
|
|
50
|
+
)
|
|
51
|
+
raise SystemExit(msg) from exc
|
|
52
|
+
return MlxLauncher, MlxSession
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def start_mlx_profile(
|
|
56
|
+
*,
|
|
57
|
+
profile_id: str,
|
|
58
|
+
folder_id: str | None,
|
|
59
|
+
token: str | None,
|
|
60
|
+
headless: bool,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Return CDP HTTP endpoint for the started MLX profile.
|
|
63
|
+
|
|
64
|
+
TODO: Uncomment and use cdp-connect-kit once peer deps are installed.
|
|
65
|
+
"""
|
|
66
|
+
cdp_url = os.environ.get("CDP_URL")
|
|
67
|
+
if cdp_url:
|
|
68
|
+
print(f"Using CDP_URL from environment: {cdp_url}")
|
|
69
|
+
return cdp_url.rstrip("/")
|
|
70
|
+
|
|
71
|
+
# --- Launcher start (cdp-connect-kit pattern) ---
|
|
72
|
+
# TODO: Remove the guard below and call MlxLauncher when ready for production.
|
|
73
|
+
if os.environ.get("HUMAN_INPUT_KIT_MLX_DEMO") != "1":
|
|
74
|
+
print(
|
|
75
|
+
"Pseudocode mode: Launcher start is stubbed.\n"
|
|
76
|
+
" 1. pip install cdp-connect-kit[mlx]\n"
|
|
77
|
+
" 2. export MLX_TOKEN=... MLX_FOLDER_ID=...\n"
|
|
78
|
+
" 3. export HUMAN_INPUT_KIT_MLX_DEMO=1\n"
|
|
79
|
+
" 4. Re-run this script\n"
|
|
80
|
+
"Or export CDP_URL=http://127.0.0.1:PORT from an already-started profile."
|
|
81
|
+
)
|
|
82
|
+
raise SystemExit(2)
|
|
83
|
+
|
|
84
|
+
resolved_folder = folder_id or os.environ.get("MLX_FOLDER_ID")
|
|
85
|
+
resolved_token = token or os.environ.get("MLX_TOKEN")
|
|
86
|
+
if not resolved_folder or not resolved_token:
|
|
87
|
+
raise SystemExit(
|
|
88
|
+
"Set MLX_FOLDER_ID + MLX_TOKEN (or pass --folder-id / --token), "
|
|
89
|
+
"or export CDP_URL to skip Launcher start."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
MlxLauncher, _MlxSession = _require_cdp_connect_kit()
|
|
93
|
+
launcher = MlxLauncher(token=resolved_token)
|
|
94
|
+
try:
|
|
95
|
+
session = launcher.start_profile(
|
|
96
|
+
resolved_folder,
|
|
97
|
+
profile_id,
|
|
98
|
+
automation_type="playwright",
|
|
99
|
+
headless=headless,
|
|
100
|
+
)
|
|
101
|
+
print(f"MLX Launcher started profile {profile_id} → {session.http_endpoint}")
|
|
102
|
+
return session.http_endpoint
|
|
103
|
+
finally:
|
|
104
|
+
launcher.close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def stop_mlx_profile(*, profile_id: str, token: str | None) -> None:
|
|
108
|
+
"""Stop MLX profile via Launcher API."""
|
|
109
|
+
if os.environ.get("CDP_URL"):
|
|
110
|
+
print("CDP_URL was supplied externally — skipping Launcher stop.")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# TODO: Call launcher.stop_profile(profile_id) when HUMAN_INPUT_KIT_MLX_DEMO=1.
|
|
114
|
+
if os.environ.get("HUMAN_INPUT_KIT_MLX_DEMO") != "1":
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
MlxLauncher, _ = _require_cdp_connect_kit()
|
|
118
|
+
resolved_token = token or os.environ.get("MLX_TOKEN")
|
|
119
|
+
if not resolved_token:
|
|
120
|
+
return
|
|
121
|
+
launcher = MlxLauncher(token=resolved_token)
|
|
122
|
+
try:
|
|
123
|
+
launcher.stop_profile(profile_id)
|
|
124
|
+
print(f"Stopped MLX profile {profile_id}")
|
|
125
|
+
finally:
|
|
126
|
+
launcher.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def warmup_over_cdp(
|
|
130
|
+
*,
|
|
131
|
+
cdp_endpoint: str,
|
|
132
|
+
url: str,
|
|
133
|
+
seed: int,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Connect Playwright over CDP and run human-input-kit warmup gestures."""
|
|
136
|
+
seed_rng(seed)
|
|
137
|
+
async with async_playwright() as playwright:
|
|
138
|
+
browser = await playwright.chromium.connect_over_cdp(cdp_endpoint)
|
|
139
|
+
context = browser.contexts[0] if browser.contexts else await browser.new_context()
|
|
140
|
+
page = context.pages[0] if context.pages else await context.new_page()
|
|
141
|
+
await page.goto(url, wait_until="domcontentloaded")
|
|
142
|
+
|
|
143
|
+
path = bezier_mouse_path((80, 120), (640, 360), steps=20)
|
|
144
|
+
await move_mouse_along(page, path)
|
|
145
|
+
await random_scroll(page, bursts=4)
|
|
146
|
+
await idle_jitter(page, moves=3)
|
|
147
|
+
|
|
148
|
+
title = await page.title()
|
|
149
|
+
print(f"MLX warmup complete (seed={seed}): {title}")
|
|
150
|
+
await browser.close()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def mlx_warmup_flow(
|
|
154
|
+
*,
|
|
155
|
+
profile_id: str,
|
|
156
|
+
folder_id: str | None,
|
|
157
|
+
token: str | None,
|
|
158
|
+
url: str,
|
|
159
|
+
seed: int,
|
|
160
|
+
headless: bool,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""End-to-end: Launcher start → CDP connect → warmup → Launcher stop."""
|
|
163
|
+
cdp_endpoint = await start_mlx_profile(
|
|
164
|
+
profile_id=profile_id,
|
|
165
|
+
folder_id=folder_id,
|
|
166
|
+
token=token,
|
|
167
|
+
headless=headless,
|
|
168
|
+
)
|
|
169
|
+
try:
|
|
170
|
+
await warmup_over_cdp(cdp_endpoint=cdp_endpoint, url=url, seed=seed)
|
|
171
|
+
finally:
|
|
172
|
+
await stop_mlx_profile(profile_id=profile_id, token=token)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main() -> None:
|
|
176
|
+
parser = argparse.ArgumentParser(
|
|
177
|
+
description="MLX profile warmup demo (peer: cdp-connect-kit[mlx])",
|
|
178
|
+
)
|
|
179
|
+
parser.add_argument("--profile-id", required=True, help="MLX profile UUID")
|
|
180
|
+
parser.add_argument("--folder-id", default=None, help="MLX folder UUID (or MLX_FOLDER_ID)")
|
|
181
|
+
parser.add_argument("--token", default=None, help="Bearer token (or MLX_TOKEN)")
|
|
182
|
+
parser.add_argument("--url", default="https://news.ycombinator.com")
|
|
183
|
+
parser.add_argument("--seed", type=int, default=42)
|
|
184
|
+
parser.add_argument("--headed", action="store_true", help="Launcher headless_mode=false")
|
|
185
|
+
args = parser.parse_args()
|
|
186
|
+
|
|
187
|
+
asyncio.run(
|
|
188
|
+
mlx_warmup_flow(
|
|
189
|
+
profile_id=args.profile_id,
|
|
190
|
+
folder_id=args.folder_id,
|
|
191
|
+
token=args.token,
|
|
192
|
+
url=args.url,
|
|
193
|
+
seed=args.seed,
|
|
194
|
+
headless=not args.headed,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
main()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Human-like input helpers for Playwright (sync API by default)."""
|
|
2
|
+
|
|
3
|
+
from human_input_kit import async_ as async_
|
|
4
|
+
from human_input_kit.bezier import Point, bezier_mouse_path
|
|
5
|
+
from human_input_kit.rng import get_rng, seed_rng
|
|
6
|
+
from human_input_kit.sync_ import human_type, idle_jitter, move_mouse_along, random_scroll
|
|
7
|
+
from human_input_kit.sync_ import replay_file as replay_file
|
|
8
|
+
from human_input_kit.sync_ import replay_gestures as replay_gestures
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Point",
|
|
12
|
+
"async_",
|
|
13
|
+
"bezier_mouse_path",
|
|
14
|
+
"get_rng",
|
|
15
|
+
"human_type",
|
|
16
|
+
"idle_jitter",
|
|
17
|
+
"move_mouse_along",
|
|
18
|
+
"random_scroll",
|
|
19
|
+
"replay_file",
|
|
20
|
+
"replay_gestures",
|
|
21
|
+
"seed_rng",
|
|
22
|
+
]
|
|
23
|
+
__version__ = "0.2.1"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Playwright async API — human-like input helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from human_input_kit.bezier import bezier_mouse_path, move_mouse_along
|
|
6
|
+
from human_input_kit.idle import idle_jitter
|
|
7
|
+
from human_input_kit.keyboard import human_type
|
|
8
|
+
from human_input_kit.replay import replay_file, replay_gestures
|
|
9
|
+
from human_input_kit.scroll import random_scroll
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"bezier_mouse_path",
|
|
13
|
+
"human_type",
|
|
14
|
+
"idle_jitter",
|
|
15
|
+
"move_mouse_along",
|
|
16
|
+
"random_scroll",
|
|
17
|
+
"replay_file",
|
|
18
|
+
"replay_gestures",
|
|
19
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Bezier mouse path generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from human_input_kit.rng import get_rng
|
|
9
|
+
|
|
10
|
+
Point = tuple[float, float]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _cubic_bezier(t: float, p0: Point, p1: Point, p2: Point, p3: Point) -> Point:
|
|
14
|
+
u = 1.0 - t
|
|
15
|
+
x = u**3 * p0[0] + 3 * u**2 * t * p1[0] + 3 * u * t**2 * p2[0] + t**3 * p3[0]
|
|
16
|
+
y = u**3 * p0[1] + 3 * u**2 * t * p1[1] + 3 * u * t**2 * p2[1] + t**3 * p3[1]
|
|
17
|
+
return x, y
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def bezier_mouse_path(
|
|
21
|
+
start: Point,
|
|
22
|
+
end: Point,
|
|
23
|
+
*,
|
|
24
|
+
steps: int = 25,
|
|
25
|
+
spread: float = 0.35,
|
|
26
|
+
) -> list[Point]:
|
|
27
|
+
"""Return a human-like curved mouse path between two points."""
|
|
28
|
+
rng = get_rng()
|
|
29
|
+
if steps < 2:
|
|
30
|
+
return [start, end]
|
|
31
|
+
|
|
32
|
+
dx = end[0] - start[0]
|
|
33
|
+
dy = end[1] - start[1]
|
|
34
|
+
distance = math.hypot(dx, dy) or 1.0
|
|
35
|
+
|
|
36
|
+
offset = distance * spread
|
|
37
|
+
cp1 = (
|
|
38
|
+
start[0] + dx * 0.25 + rng.uniform(-offset, offset),
|
|
39
|
+
start[1] + dy * 0.25 + rng.uniform(-offset, offset),
|
|
40
|
+
)
|
|
41
|
+
cp2 = (
|
|
42
|
+
start[0] + dx * 0.75 + rng.uniform(-offset, offset),
|
|
43
|
+
start[1] + dy * 0.75 + rng.uniform(-offset, offset),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
points: list[Point] = []
|
|
47
|
+
jitter = min(3.0, distance * 0.01)
|
|
48
|
+
for i in range(steps):
|
|
49
|
+
t = i / (steps - 1)
|
|
50
|
+
x, y = _cubic_bezier(t, start, cp1, cp2, end)
|
|
51
|
+
if i in {0, steps - 1}:
|
|
52
|
+
points.append((x, y))
|
|
53
|
+
else:
|
|
54
|
+
points.append((x + rng.uniform(-jitter, jitter), y + rng.uniform(-jitter, jitter)))
|
|
55
|
+
points[0] = start
|
|
56
|
+
points[-1] = end
|
|
57
|
+
return points
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def move_mouse_along(page: object, path: Sequence[Point], *, delay_ms: float = 8.0) -> None:
|
|
61
|
+
"""Move Playwright mouse along a bezier path."""
|
|
62
|
+
import asyncio
|
|
63
|
+
|
|
64
|
+
mouse = page.mouse # type: ignore[attr-defined]
|
|
65
|
+
for x, y in path:
|
|
66
|
+
await mouse.move(x, y)
|
|
67
|
+
if delay_ms > 0:
|
|
68
|
+
await asyncio.sleep(delay_ms / 1000.0)
|
human_input_kit/cli.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Click CLI for human-input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from playwright.async_api import async_playwright
|
|
10
|
+
|
|
11
|
+
from human_input_kit.gestures import load_recording, save_recording
|
|
12
|
+
from human_input_kit.idle import idle_jitter
|
|
13
|
+
from human_input_kit.record import record_gestures
|
|
14
|
+
from human_input_kit.replay import replay_file
|
|
15
|
+
from human_input_kit.rng import seed_rng
|
|
16
|
+
from human_input_kit.scroll import random_scroll
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group(invoke_without_command=True)
|
|
20
|
+
@click.option("--show-deal", is_flag=True, help="Print Multilogin coupon info and exit.")
|
|
21
|
+
@click.option("--seed", default=42, show_default=True, help="RNG seed for reproducible motion.")
|
|
22
|
+
@click.version_option(package_name="human-input-kit")
|
|
23
|
+
@click.pass_context
|
|
24
|
+
def main(ctx: click.Context, show_deal: bool, seed: int) -> None:
|
|
25
|
+
"""Human-like Playwright input helpers."""
|
|
26
|
+
if show_deal:
|
|
27
|
+
from human_input_kit.deal import print_show_deal
|
|
28
|
+
|
|
29
|
+
print_show_deal()
|
|
30
|
+
ctx.exit(0)
|
|
31
|
+
seed_rng(seed)
|
|
32
|
+
ctx.ensure_object(dict)
|
|
33
|
+
ctx.obj["seed"] = seed
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@main.command("demo-scroll")
|
|
37
|
+
@click.option("--url", default="https://news.ycombinator.com", show_default=True)
|
|
38
|
+
@click.option("--headless/--no-headless", default=True, show_default=True)
|
|
39
|
+
@click.option("--bursts", default=5, show_default=True)
|
|
40
|
+
def demo_scroll_cmd(url: str, headless: bool, bursts: int) -> None:
|
|
41
|
+
"""Open a page and perform human-like scrolling + idle jitter."""
|
|
42
|
+
|
|
43
|
+
async def _run() -> None:
|
|
44
|
+
async with async_playwright() as playwright:
|
|
45
|
+
browser = await playwright.chromium.launch(headless=headless)
|
|
46
|
+
page = await browser.new_page(viewport={"width": 1280, "height": 720})
|
|
47
|
+
await page.goto(url, wait_until="domcontentloaded")
|
|
48
|
+
await random_scroll(page, bursts=bursts)
|
|
49
|
+
await idle_jitter(page, moves=5)
|
|
50
|
+
await browser.close()
|
|
51
|
+
|
|
52
|
+
asyncio.run(_run())
|
|
53
|
+
click.echo(f"Completed demo scroll on {url}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@main.command("record")
|
|
57
|
+
@click.option("--url", default="https://news.ycombinator.com", show_default=True)
|
|
58
|
+
@click.option("--duration", default=30.0, show_default=True, help="Seconds to record.")
|
|
59
|
+
@click.option("-o", "--output", "output_path", type=click.Path(path_type=Path), required=True)
|
|
60
|
+
@click.option("--headless/--no-headless", default=True, show_default=True)
|
|
61
|
+
def record_cmd(url: str, duration: float, output_path: Path, headless: bool) -> None:
|
|
62
|
+
"""Record gestures to JSON (includes scripted warmup actions)."""
|
|
63
|
+
|
|
64
|
+
async def _run() -> None:
|
|
65
|
+
async with async_playwright() as playwright:
|
|
66
|
+
browser = await playwright.chromium.launch(headless=headless)
|
|
67
|
+
page = await browser.new_page(viewport={"width": 1280, "height": 720})
|
|
68
|
+
await page.goto(url, wait_until="domcontentloaded")
|
|
69
|
+
recording = await record_gestures(page, duration=duration)
|
|
70
|
+
save_recording(output_path, recording)
|
|
71
|
+
await browser.close()
|
|
72
|
+
|
|
73
|
+
asyncio.run(_run())
|
|
74
|
+
click.echo(f"Saved {duration:.0f}s recording -> {output_path}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@main.command("replay")
|
|
78
|
+
@click.argument("gestures_path", type=click.Path(exists=True, path_type=Path))
|
|
79
|
+
@click.option("--headless/--no-headless", default=True, show_default=True)
|
|
80
|
+
@click.option("--speed", default=1.0, show_default=True, help="Playback speed multiplier.")
|
|
81
|
+
def replay_cmd(gestures_path: Path, headless: bool, speed: float) -> None:
|
|
82
|
+
"""Replay a gestures JSON file."""
|
|
83
|
+
try:
|
|
84
|
+
load_recording(gestures_path, validate=True)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
raise click.ClickException(str(exc)) from exc
|
|
87
|
+
|
|
88
|
+
async def _run() -> None:
|
|
89
|
+
async with async_playwright() as playwright:
|
|
90
|
+
browser = await playwright.chromium.launch(headless=headless)
|
|
91
|
+
page = await browser.new_page(viewport={"width": 1280, "height": 720})
|
|
92
|
+
await replay_file(page, gestures_path, speed=speed)
|
|
93
|
+
await browser.close()
|
|
94
|
+
|
|
95
|
+
asyncio.run(_run())
|
|
96
|
+
click.echo(f"Replayed {gestures_path}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
main()
|
human_input_kit/deal.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Multilogin partner coupon output for --show-deal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
SHOW_DEAL_TEXT = """Partner info (affiliate links — optional, not required for human-input)
|
|
8
|
+
|
|
9
|
+
Human-like scroll and mouse paths help cadence — not fingerprint or IP.
|
|
10
|
+
They matter most inside isolated Launcher profiles at folder scale.
|
|
11
|
+
|
|
12
|
+
Multilogin X — antidetect browser & cloud phone (verify eligibility before checkout)
|
|
13
|
+
|
|
14
|
+
SAAS50 — browser plans (eligible new purchases)
|
|
15
|
+
MIN50 — cloud phone (eligible new purchases)
|
|
16
|
+
|
|
17
|
+
https://multilogin.com/?ref=SAAS50
|
|
18
|
+
Coupons: https://anti-detect.github.io/
|
|
19
|
+
Scripts: https://t.me/Multilogin_Scripts_Bot
|
|
20
|
+
|
|
21
|
+
Disclosure: we may earn a commission. Offers change; confirm on vendor site."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def print_show_deal() -> None:
|
|
25
|
+
click.echo(SHOW_DEAL_TEXT)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Gesture recording JSON schema validation (no external jsonschema dependency)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
ALLOWED_EVENT_TYPES = frozenset({"move", "scroll", "type", "wait"})
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_gesture_payload(payload: Any) -> list[str]:
|
|
11
|
+
"""Return a list of validation error messages (empty if valid)."""
|
|
12
|
+
errors: list[str] = []
|
|
13
|
+
|
|
14
|
+
if not isinstance(payload, dict):
|
|
15
|
+
return ["Gesture file must be a JSON object"]
|
|
16
|
+
|
|
17
|
+
version = payload.get("version")
|
|
18
|
+
if version is None:
|
|
19
|
+
errors.append("Missing required field 'version'")
|
|
20
|
+
elif not isinstance(version, int) or version < 1:
|
|
21
|
+
errors.append("'version' must be a positive integer")
|
|
22
|
+
|
|
23
|
+
url = payload.get("url", "about:blank")
|
|
24
|
+
if not isinstance(url, str):
|
|
25
|
+
errors.append("'url' must be a string")
|
|
26
|
+
|
|
27
|
+
events = payload.get("events")
|
|
28
|
+
if events is None:
|
|
29
|
+
errors.append("Missing required field 'events'")
|
|
30
|
+
return errors
|
|
31
|
+
if not isinstance(events, list):
|
|
32
|
+
errors.append("'events' must be an array")
|
|
33
|
+
return errors
|
|
34
|
+
|
|
35
|
+
for index, item in enumerate(events):
|
|
36
|
+
prefix = f"events[{index}]"
|
|
37
|
+
if not isinstance(item, dict):
|
|
38
|
+
errors.append(f"{prefix} must be an object")
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
event_type = item.get("type")
|
|
42
|
+
if event_type not in ALLOWED_EVENT_TYPES:
|
|
43
|
+
errors.append(f"{prefix}.type must be one of {sorted(ALLOWED_EVENT_TYPES)}")
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
t = item.get("t")
|
|
47
|
+
if t is None:
|
|
48
|
+
errors.append(f"{prefix} missing required field 't'")
|
|
49
|
+
elif not isinstance(t, (int, float)) or t < 0:
|
|
50
|
+
errors.append(f"{prefix}.t must be a non-negative number")
|
|
51
|
+
|
|
52
|
+
errors.extend(_validate_event_fields(prefix, event_type, item))
|
|
53
|
+
|
|
54
|
+
return errors
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _validate_event_fields(prefix: str, event_type: str, item: dict[str, Any]) -> list[str]:
|
|
58
|
+
errors: list[str] = []
|
|
59
|
+
|
|
60
|
+
if event_type == "move":
|
|
61
|
+
for field in ("x", "y"):
|
|
62
|
+
value = item.get(field)
|
|
63
|
+
if value is None:
|
|
64
|
+
errors.append(f"{prefix} move events require '{field}'")
|
|
65
|
+
elif not isinstance(value, (int, float)):
|
|
66
|
+
errors.append(f"{prefix}.{field} must be a number")
|
|
67
|
+
elif event_type == "scroll":
|
|
68
|
+
if item.get("dx") is None and item.get("dy") is None:
|
|
69
|
+
errors.append(f"{prefix} scroll events require 'dx' and/or 'dy'")
|
|
70
|
+
for field in ("dx", "dy"):
|
|
71
|
+
value = item.get(field)
|
|
72
|
+
if value is not None and not isinstance(value, (int, float)):
|
|
73
|
+
errors.append(f"{prefix}.{field} must be a number")
|
|
74
|
+
elif event_type == "type":
|
|
75
|
+
text = item.get("text")
|
|
76
|
+
if text is None:
|
|
77
|
+
errors.append(f"{prefix} type events require 'text'")
|
|
78
|
+
elif not isinstance(text, str):
|
|
79
|
+
errors.append(f"{prefix}.text must be a string")
|
|
80
|
+
selector = item.get("selector")
|
|
81
|
+
if selector is not None and not isinstance(selector, str):
|
|
82
|
+
errors.append(f"{prefix}.selector must be a string")
|
|
83
|
+
elif event_type == "wait":
|
|
84
|
+
duration = item.get("duration_ms")
|
|
85
|
+
if duration is None:
|
|
86
|
+
errors.append(f"{prefix} wait events require 'duration_ms'")
|
|
87
|
+
elif not isinstance(duration, (int, float)) or duration <= 0:
|
|
88
|
+
errors.append(f"{prefix}.duration_ms must be a positive number")
|
|
89
|
+
|
|
90
|
+
return errors
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Gesture recording format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
GestureType = Literal["move", "scroll", "type", "wait"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class GestureEvent:
|
|
15
|
+
type: GestureType
|
|
16
|
+
t: float
|
|
17
|
+
x: float | None = None
|
|
18
|
+
y: float | None = None
|
|
19
|
+
dx: float | None = None
|
|
20
|
+
dy: float | None = None
|
|
21
|
+
text: str | None = None
|
|
22
|
+
selector: str | None = None
|
|
23
|
+
duration_ms: float | None = None
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, Any]:
|
|
26
|
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class GestureRecording:
|
|
31
|
+
version: int = 1
|
|
32
|
+
url: str = "about:blank"
|
|
33
|
+
events: list[GestureEvent] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
return {
|
|
37
|
+
"version": self.version,
|
|
38
|
+
"url": self.url,
|
|
39
|
+
"events": [event.to_dict() for event in self.events],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_dict(cls, payload: dict[str, Any]) -> GestureRecording:
|
|
44
|
+
events = []
|
|
45
|
+
for item in payload.get("events", []):
|
|
46
|
+
if isinstance(item, dict):
|
|
47
|
+
events.append(
|
|
48
|
+
GestureEvent(
|
|
49
|
+
type=item["type"],
|
|
50
|
+
t=float(item.get("t", 0)),
|
|
51
|
+
x=item.get("x"),
|
|
52
|
+
y=item.get("y"),
|
|
53
|
+
dx=item.get("dx"),
|
|
54
|
+
dy=item.get("dy"),
|
|
55
|
+
text=item.get("text"),
|
|
56
|
+
selector=item.get("selector"),
|
|
57
|
+
duration_ms=item.get("duration_ms"),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return cls(
|
|
61
|
+
version=int(payload.get("version", 1)),
|
|
62
|
+
url=str(payload.get("url", "about:blank")),
|
|
63
|
+
events=events,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def save_recording(path: Path | str, recording: GestureRecording) -> None:
|
|
68
|
+
target = Path(path)
|
|
69
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
target.write_text(json.dumps(recording.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_recording(path: Path | str, *, validate: bool = True) -> GestureRecording:
|
|
74
|
+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
75
|
+
if validate:
|
|
76
|
+
from human_input_kit.gesture_schema import validate_gesture_payload
|
|
77
|
+
|
|
78
|
+
errors = validate_gesture_payload(payload)
|
|
79
|
+
if errors:
|
|
80
|
+
msg = "Invalid gesture file:\n" + "\n".join(f" - {err}" for err in errors)
|
|
81
|
+
raise ValueError(msg)
|
|
82
|
+
if not isinstance(payload, dict):
|
|
83
|
+
raise ValueError("Gesture file must be a JSON object")
|
|
84
|
+
return GestureRecording.from_dict(payload)
|