feat: Linux bulk importer for the Allflame Voyage Solver

Mirrors the solver's Windows-only voyage-import.ahk on KDE Wayland:

- poe-clip-bridge.py: clipboard watcher that keeps only PoE item text,
  serves it on loopback and can trigger a sweep from the browser
- voyage-sweep.py: hovers every cell of the in-game chart grid, Ctrl+C's
  it and hands over the whole batch in one go
- userscript: feeds the batch into the solver page without focusing it

Includes notes on the four Wayland pointer pitfalls this ran into.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 21:39:19 +02:00
co-authored by Claude Opus 5
commit e65f9db3ae
9 changed files with 928 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Clipboard bridge for the Allflame Voyage Solver.
Watches the Wayland clipboard, keeps ONLY Path of Exile item text in memory
and serves it on http://127.0.0.1:8477/clip for a Violentmonkey userscript.
Also triggers the grid sweep, so a Firefox bookmark can start it.
Security: anything that does not look like PoE item text (passwords from
KeePassXC, tokens, ...) is discarded immediately and never stored or served.
Nothing is written to disk. The socket is bound to loopback only.
Endpoints that act (sweep/pause/resume) require the token from
~/.config/poe-clip-bridge/token - otherwise any web page could start a sweep
that takes over mouse and keyboard.
"""
import json
import os
import pathlib
import re
import secrets
import subprocess
import threading
import urllib.parse
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOST, PORT = "127.0.0.1", 8477
ORIGIN = "https://one-more-map.github.io"
TOKEN_FILE = pathlib.Path.home() / ".config" / "poe-clip-bridge" / "token"
SWEEP = str(pathlib.Path.home() / ".local" / "bin" / "voyage-sweep.py")
# Only clipboard content matching this ever leaves the watcher.
ITEM_RE = re.compile(
r"^[ \t]*(?:Item Class|Rarity)\s*[:]|===\s*VOYAGE BORDER",
re.IGNORECASE | re.MULTILINE,
)
state = {"seq": 0, "text": ""}
paused = False
sweep_proc = None
sweep_log = deque(maxlen=40)
lock = threading.Lock()
def load_token() -> str:
if TOKEN_FILE.exists():
return TOKEN_FILE.read_text().strip()
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
token = secrets.token_urlsafe(24)
TOKEN_FILE.write_text(token + "\n")
TOKEN_FILE.chmod(0o600)
return token
TOKEN = load_token()
def publish(text: str) -> None:
"""Store item text; called by the watcher and once again on resume."""
if not ITEM_RE.search(text):
return
with lock:
if text != state["text"]:
state["seq"] += 1
state["text"] = text
print(f"[bridge] item #{state['seq']} ({len(text)} chars)", flush=True)
def watch() -> None:
"""Stream clipboard changes as NUL-delimited records from wl-paste."""
proc = subprocess.Popen(
["wl-paste", "--watch", "sh", "-c", "cat; printf '\\0'"],
stdout=subprocess.PIPE,
)
buf = b""
while True:
chunk = proc.stdout.read1(4096) # read1: do not block for a full buffer
if not chunk:
break
buf += chunk
while b"\0" in buf:
raw, buf = buf.split(b"\0", 1)
if paused: # during a sweep every single copy would import separately
continue
publish(raw.decode("utf-8", "replace").strip())
def read_clipboard() -> str:
r = subprocess.run(["wl-paste", "--no-newline", "--type", "text/plain"],
capture_output=True, text=True)
return r.stdout.strip() if r.returncode == 0 else ""
def run_sweep() -> None:
"""Run the grid sweep, capturing its output for the status page."""
global sweep_proc, paused
paused = True
sweep_log.clear()
sweep_log.append("sweep started")
try:
sweep_proc = subprocess.Popen(
["python3", "-u", SWEEP, "run", "--delay", "4"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
for line in sweep_proc.stdout:
for part in line.replace("\r", "\n").splitlines():
if part.strip():
sweep_log.append(part.strip())
sweep_proc.wait()
sweep_log.append(f"sweep finished (exit {sweep_proc.returncode})")
except Exception as exc: # noqa: BLE001 - surfaced on the status page
sweep_log.append(f"sweep failed: {exc}")
finally:
sweep_proc = None
paused = False
publish(read_clipboard()) # the batch the sweep just put there
STATUS_PAGE = """<!doctype html><meta charset=utf-8>
<title>Voyage sweep</title>
<style>body{font:14px/1.5 monospace;background:#151515;color:#ddd;margin:2rem}
h1{font-size:1rem;color:#8f8}pre{white-space:pre-wrap}</style>
<h1>Voyage sweep</h1><pre id=log>...</pre>
<script>
const t = new URLSearchParams(location.search).get('token');
setInterval(async () => {
const r = await fetch('/log?token=' + encodeURIComponent(t));
const d = await r.json();
document.getElementById('log').textContent =
d.lines.join('\\n') + (d.running ? '\\n\\n(laeuft...)' : '\\n\\n(fertig)');
}, 1000);
</script>"""
class Handler(BaseHTTPRequestHandler):
def _send(self, body, ctype="application/json", code=200):
data = body.encode() if isinstance(body, str) else body
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", ORIGIN)
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
def _authed(self, query):
return secrets.compare_digest(query.get("token", [""])[0], TOKEN)
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
query = urllib.parse.parse_qs(parsed.query)
path = parsed.path
if path == "/clip":
with lock:
self._send(json.dumps(state))
return
if path in ("/sweep", "/log", "/pause", "/resume"):
if not self._authed(query):
self._send('{"error":"bad token"}', code=403)
return
global paused
if path == "/sweep":
if sweep_proc is None:
threading.Thread(target=run_sweep, daemon=True).start()
self._send(STATUS_PAGE, ctype="text/html; charset=utf-8")
elif path == "/log":
self._send(json.dumps({"running": sweep_proc is not None,
"lines": list(sweep_log)}))
elif path == "/pause":
paused = True
self._send('{"paused":true}')
elif path == "/resume":
paused = False
publish(read_clipboard())
self._send('{"paused":false}')
else:
self.send_error(404)
def log_message(self, *args):
pass
if __name__ == "__main__":
threading.Thread(target=watch, daemon=True).start()
print(f"[bridge] listening on http://{HOST}:{PORT}/clip", flush=True)
print(f"[bridge] sweep bookmark: http://{HOST}:{PORT}/sweep?token={TOKEN}",
flush=True)
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Print the personal bookmarklet that triggers a grid sweep from the browser.
set -euo pipefail
token_file="${XDG_CONFIG_HOME:-$HOME/.config}/poe-clip-bridge/token"
if [[ ! -f $token_file ]]; then
echo "No token yet - start the bridge once: systemctl --user start poe-clip-bridge.service" >&2
exit 1
fi
token=$(cat "$token_file")
echo "Add this as a bookmark (Ctrl+Shift+O -> new bookmark, paste into URL):"
echo
echo "javascript:fetch('http://127.0.0.1:8477/sweep?token=${token}');void 0"
echo
echo "Status page (opens a tab instead of staying on the solver page):"
echo "http://127.0.0.1:8477/sweep?token=${token}"
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""Bulk-import PoE Charts into the Allflame Voyage Solver (Linux/KDE Wayland).
Linux counterpart of the site's voyage-import.ahk: hovers every cell of the
in-game chart grid, Ctrl+C's it, collects the item texts and puts the whole
batch into the clipboard, where poe-clip-bridge + the Violentmonkey userscript
pick it up and paste it into the solver.
voyage-sweep.py calibrate # capture the grid corners
voyage-sweep.py run # do the sweep
"""
import argparse
import json
import os
import pathlib
import re
import subprocess
import sys
import time
import urllib.parse
import urllib.request
CONFIG = pathlib.Path.home() / ".config" / "voyage-sweep.json"
DEFAULTS = {
"cols": 6,
"rows": 10,
"tl": None, # [x, y] centre of the top-left cell
"br": None, # [x, y] centre of the bottom-right cell
"hover_delay": 0.14, # let the tooltip build before Ctrl+C
"clip_timeout": 0.6, # how long to wait for clipboard content
"poe_window": "Path of Exile",
}
CHART_RE = re.compile(r"^[ \t]*Item Class\s*[:]", re.IGNORECASE | re.MULTILINE)
MAX_STEP = 150 # larger relative jumps get distorted by pointer acceleration
ENV = {**os.environ, "YDOTOOL_SOCKET": f"/run/user/{os.getuid()}/.ydotool_socket"}
def run(cmd, **kw):
return subprocess.run(cmd, env=ENV, capture_output=True, text=True, **kw)
def load_config():
cfg = dict(DEFAULTS)
if CONFIG.exists():
cfg.update(json.loads(CONFIG.read_text()))
return cfg
def save_config(cfg):
CONFIG.write_text(json.dumps(cfg, indent=2) + "\n")
# ---------- pointer ----------
def cursor():
"""Pointer position. Reliable while the pointer is over an XWayland window."""
out = run(["xdotool", "getmouselocation", "--shell"]).stdout
pos = dict(l.split("=", 1) for l in out.strip().splitlines() if "=" in l)
return int(pos["X"]), int(pos["Y"])
def cursor_kwin():
"""Pointer position straight from KWin. Slow (~0.3s) but always correct,
also while the pointer sits on a Wayland-native window where X sees nothing."""
js = pathlib.Path(f"/tmp/voyage-probe-{os.getpid()}.js")
js.write_text('print("VOYAGEPROBE " + workspace.cursorPos.x + ","'
' + workspace.cursorPos.y);\n')
name = f"voyageprobe{os.getpid()}"
since = time.strftime("%Y-%m-%d %H:%M:%S")
sid = run(["qdbus6", "org.kde.KWin", "/Scripting",
"org.kde.kwin.Scripting.loadScript", str(js), name]).stdout.strip()
run(["qdbus6", "org.kde.KWin", f"/Scripting/Script{sid}",
"org.kde.kwin.Script.run"])
time.sleep(0.25)
log = run(["journalctl", "_COMM=kwin_wayland", "--since", since,
"--no-pager", "-o", "cat"]).stdout
run(["qdbus6", "org.kde.KWin", "/Scripting",
"org.kde.kwin.Scripting.unloadScript", name])
js.unlink(missing_ok=True)
hits = re.findall(r"VOYAGEPROBE (\d+),(\d+)", log)
if not hits:
raise RuntimeError("KWin cursor probe returned nothing")
return int(hits[-1][0]), int(hits[-1][1])
# Injected motion is not applied 1:1 - this display multiplies it (measured 2.0).
# The factor is learned from the actual movement instead of being hardcoded.
_gain = 1.0
def move_to(x, y, tries=40, coarse=False, tol=1):
"""Closed loop: step, re-read, correct, and learn the motion gain."""
global _gain
read = cursor_kwin if coarse else cursor
for _ in range(tries):
cx, cy = read()
dx, dy = x - cx, y - cy
if abs(dx) <= tol and abs(dy) <= tol:
return True
sx = max(-MAX_STEP, min(MAX_STEP, round(dx / _gain)))
sy = max(-MAX_STEP, min(MAX_STEP, round(dy / _gain)))
run(["ydotool", "mousemove", "-x", str(sx), "-y", str(sy)])
time.sleep(0.03)
nx, ny = read()
for sent, moved in ((sx, nx - cx), (sy, ny - cy)):
if abs(sent) >= 20 and abs(moved) >= 1:
_gain = max(0.2, min(8.0, 0.7 * _gain + 0.3 * (moved / sent)))
break
return False
def ctrl_c():
run(["ydotool", "key", "29:1", "46:1", "46:0", "29:0"]) # LEFTCTRL + C
# ---------- clipboard ----------
def clip_arm(token):
"""Put a unique marker in the clipboard.
'wl-copy --clear' is asynchronous: a poll right after it still reads the
previous owner's data, which made every cell report the first chart again.
Waiting for the marker to disappear is race-free.
"""
subprocess.run(["wl-copy"], input=token, text=True, env=ENV)
def clip_read():
r = run(["wl-paste", "--no-newline", "--type", "text/plain"])
return r.stdout if r.returncode == 0 else ""
def clip_wait(timeout, token):
"""Wait until the game replaced our marker with the copied item text."""
deadline = time.time() + timeout
while time.time() < deadline:
text = clip_read().strip()
if text and text != token:
return text
time.sleep(0.03)
return ""
# ---------- grid ----------
def poe_geometry(cfg):
"""Window origin+size of the PoE window (an XWayland client, so X knows it)."""
wid = run(["xdotool", "search", "--name", cfg["poe_window"]]).stdout.split()
if not wid:
sys.exit(f"No window named '{cfg['poe_window']}' found - is PoE running?")
out = run(["xdotool", "getwindowgeometry", "--shell", wid[-1]]).stdout
g = dict(l.split("=", 1) for l in out.strip().splitlines() if "=" in l)
return int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"])
def cell_pos(cfg, row, col, origin):
"""Grid coordinates are stored relative to the window, so moving the
PoE window does not invalidate the calibration."""
tlx, tly = cfg["tl"]
brx, bry = cfg["br"]
dx = (brx - tlx) / (cfg["cols"] - 1) if cfg["cols"] > 1 else 0
dy = (bry - tly) / (cfg["rows"] - 1) if cfg["rows"] > 1 else 0
return round(origin[0] + tlx + col * dx), round(origin[1] + tly + row * dy)
def bridge(action):
"""Pause/resume the clipboard bridge over its local HTTP API.
Not 'systemctl stop': when the sweep is started by the bridge itself
(Firefox bookmark), stopping the unit would kill this process too.
"""
token_file = pathlib.Path.home() / ".config" / "poe-clip-bridge" / "token"
if not token_file.exists():
return
token = urllib.parse.quote(token_file.read_text().strip())
try:
urllib.request.urlopen(
f"http://127.0.0.1:8477/{action}?token={token}", timeout=2).read()
except OSError:
pass # bridge not running - sweep still works, just no auto-import
def countdown(msg, seconds=5):
print(msg)
for i in range(seconds, 0, -1):
print(f" {i} ...", end="\r", flush=True)
time.sleep(1)
print(" " * 20, end="\r")
# ---------- commands ----------
def cmd_calibrate(cfg, args):
cfg["cols"], cfg["rows"] = args.cols, args.rows
ox, oy = poe_geometry(cfg)[:2]
print(f"Grid: {cfg['cols']} columns x {cfg['rows']} rows. Window at {ox},{oy}.")
countdown("\nPut the mouse on the CENTRE of the TOP-LEFT cell.")
cx, cy = cursor()
cfg["tl"] = [cx - ox, cy - oy]
print(f" top-left = {cfg['tl']} (window-relative)")
countdown("\nNow the CENTRE of the BOTTOM-RIGHT cell (even if empty).")
cx, cy = cursor()
cfg["br"] = [cx - ox, cy - oy]
print(f" bottom-right = {cfg['br']} (window-relative)")
if cfg["tl"] == cfg["br"]:
sys.exit("Both corners are identical - nothing saved.")
save_config(cfg)
print(f"\nSaved to {CONFIG}. Cell spacing: "
f"{(cfg['br'][0] - cfg['tl'][0]) / max(1, cfg['cols'] - 1):.1f} x "
f"{(cfg['br'][1] - cfg['tl'][1]) / max(1, cfg['rows'] - 1):.1f} px")
def preflight():
sock = pathlib.Path(ENV["YDOTOOL_SOCKET"])
if not sock.exists():
sys.exit("ydotoold is not running (no %s).\nStart it with: "
"systemctl --user start ydotool.service" % sock)
def cmd_run(cfg, args):
if not cfg["tl"] or not cfg["br"]:
sys.exit("Not calibrated yet - run: voyage-sweep.py calibrate")
preflight()
rows = args.rows or cfg["rows"]
ox, oy, ww, wh = poe_geometry(cfg)
subprocess.run(["kdotool", "search", "--name", cfg["poe_window"],
"windowactivate"], capture_output=True)
countdown("\nSweeping - keep hands off mouse and keyboard.", args.delay)
# The fast X11 pointer read only works while the pointer is over the game,
# so get it in there first using KWin's own (slower) cursor position.
if not move_to(ox + ww // 2, oy + wh // 2, coarse=True, tol=25):
sys.exit("Could not move the pointer into the PoE window.")
bridge("pause") # otherwise every single copy gets imported separately
charts, skipped, first, identical = [], 0, None, True
try:
for row in range(rows):
for col in range(cfg["cols"]):
x, y = cell_pos(cfg, row, col, (ox, oy))
token = f"voyage-sweep-{row}-{col}-{time.monotonic_ns()}"
clip_arm(token)
if not move_to(x, y):
print(f" cell {row + 1}/{col + 1}: pointer did not reach {x},{y}")
skipped += 1
continue
time.sleep(cfg["hover_delay"])
ctrl_c()
text = clip_wait(cfg["clip_timeout"], token)
if not text or not CHART_RE.search(text):
skipped += 1
continue
charts.append(text)
if first is None:
first = text
elif text != first:
identical = False
print(f" charts {len(charts)} skipped {skipped}", end="\r", flush=True)
except KeyboardInterrupt:
print("\nAborted.")
finally:
bridge("resume")
print(" " * 40, end="\r")
if len(charts) >= 5 and identical:
sys.exit("\nEvery cell copied the SAME chart - calibration is off "
"(or the PoE window moved). Re-run: voyage-sweep.py calibrate")
if not charts:
sys.exit(f"\nNothing copied ({skipped} empty/non-chart cells).")
blob = "\n".join(charts)
subprocess.run(["wl-copy"], input=blob, text=True)
print(f"\n{len(charts)} charts copied, {skipped} cells empty/non-chart.")
print("Batch is in the clipboard - the userscript imports it into the solver.")
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
cal = sub.add_parser("calibrate", help="capture the grid corners")
cal.add_argument("--cols", type=int, default=DEFAULTS["cols"])
cal.add_argument("--rows", type=int, default=DEFAULTS["rows"])
sweep = sub.add_parser("run", help="sweep the grid and import")
sweep.add_argument("--delay", type=int, default=5,
help="seconds before the sweep starts (default 5)")
sweep.add_argument("--rows", type=int, help="sweep only the first N rows")
args = ap.parse_args()
cfg = load_config()
{"calibrate": cmd_calibrate, "run": cmd_run}[args.cmd](cfg, args)
if __name__ == "__main__":
main()