All checks were successful
Deploy / deploy (push) Successful in 38s
Live testing against the real site found MassPrice's login is behind hCaptcha, which silently blocks fully automated username/password login outright (the form never submits) — no selector fix could work around this. Also fixed the broken _is_logged_out() heuristic: an unauthenticated visit to /Rule/List redirects to the public marketing homepage, not a /login URL, so run_rule/list_rules were silently scraping the wrong page instead of erroring. New flow: a Session page lets the user start a headless-but-CDP-exposed Chromium instance, log in themselves via chrome://inspect (solving the captcha as a real click), then capture that browser's cookies into the same auth_state.json scheduled runs already use. Required a socat proxy in the container image, since Chromium's debug port binds loopback-only regardless of --remote-debugging-address (confirmed by live testing), unreachable through Docker's port mapping without it. All Playwright objects for this now live on one dedicated thread (automation.py's _debug_worker), since the sync API isn't safe to call across the arbitrary threads gunicorn's gthread workers hand out per request. Dashboard now shows a banner when the latest run failed specifically due to a missing/expired session, rather than just looking like an empty rule list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H6TgffxTvYhqxykAbVQg2v
341 lines
12 KiB
Python
341 lines
12 KiB
Python
"""Playwright automation against massprice.tcgplayer.com.
|
|
|
|
Drives the real page rather than replaying a raw HTTP request: "Run Rule"
|
|
is bound to a JS/AJAX click handler with no plain href, and the request
|
|
it fires is almost certainly anti-forgery-token protected.
|
|
|
|
Session handling: a Playwright `storage_state` (cookies + localStorage)
|
|
is persisted to AUTH_STATE_PATH and reused across runs, so scheduled runs
|
|
never touch the login form at all. That's deliberate, not just an
|
|
optimization — MassPrice's real login (on store.tcgplayer.com, TCGplayer's
|
|
shared OAuth identity provider) is behind hCaptcha, confirmed by live
|
|
testing: filling the real username/password and submitting silently
|
|
never navigates away, because the invisible captcha check never resolves
|
|
for a headless/automated browser. **There is no way to fully automate
|
|
logging in.** A human has to solve that captcha at least once per
|
|
session lifetime, via the "Chrome remote debugging" capture flow below.
|
|
|
|
Capture flow: `start_debug_session()` launches a headless-but-CDP-exposed
|
|
Chromium instance (`--remote-debugging-port`), which the user connects to
|
|
from their own Chrome via chrome://inspect to log in interactively —
|
|
solving the hCaptcha themselves, since it's a real click. Once logged in,
|
|
`capture_debug_session()` saves that browser's cookies to
|
|
AUTH_STATE_PATH. All of this must happen on a single dedicated thread —
|
|
Playwright's sync API is not safe to call from arbitrary threads, and
|
|
Flask/gunicorn's gthread workers hand each request to a different one —
|
|
so `_debug_worker()` owns every Playwright object here and talks to
|
|
Flask request threads only through `_debug_cmd_queue`.
|
|
"""
|
|
import logging
|
|
import os
|
|
import queue
|
|
import subprocess
|
|
import threading
|
|
from datetime import datetime, timezone
|
|
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
import storage
|
|
|
|
logger = logging.getLogger("massprice.automation")
|
|
|
|
BASE_URL = "https://massprice.tcgplayer.com"
|
|
AUTH_STATE_PATH = os.path.join(storage.DATA_DIR, "auth_state.json")
|
|
|
|
# Chromium's remote-debugging port binds to 127.0.0.1 only inside the
|
|
# container, no matter what --remote-debugging-address is set to —
|
|
# deliberate Chromium security hardening (confirmed by live testing, not
|
|
# a flag mistake). A loopback-only listener is unreachable through
|
|
# Docker's port mapping, so `socat` re-exposes it on all interfaces at a
|
|
# second, different internal port (they can't share one — Chromium's
|
|
# 127.0.0.1 bind and a 0.0.0.0 bind on the same port conflict). Neither
|
|
# of these is the DEBUG_PORT env var app.py reads for display — that one
|
|
# is the user-facing HOST port for chrome://inspect instructions, mapped
|
|
# in docker-compose.yml to _PROXY_PORT, not to Chromium's own port.
|
|
_INTERNAL_DEBUG_PORT = 9222
|
|
_PROXY_PORT = 9333
|
|
DEBUG_SESSION_TIMEOUT = 600 # auto-close an un-captured debug session after 10 minutes
|
|
|
|
# Standard, necessary mitigations for headless Chromium inside Docker.
|
|
LAUNCH_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"]
|
|
|
|
|
|
class AutomationError(Exception):
|
|
pass
|
|
|
|
|
|
def _new_context(browser):
|
|
if os.path.exists(AUTH_STATE_PATH):
|
|
return browser.new_context(storage_state=AUTH_STATE_PATH)
|
|
return browser.new_context()
|
|
|
|
|
|
def _save_state(context):
|
|
os.makedirs(storage.DATA_DIR, exist_ok=True)
|
|
context.storage_state(path=AUTH_STATE_PATH)
|
|
|
|
|
|
def _is_logged_out(page):
|
|
"""An unauthenticated visit to any /Rule/* page silently redirects to
|
|
the public marketing homepage (`/`) — there's no `/login` URL and no
|
|
password field to detect, so "did we get bounced off of /Rule" is the
|
|
only reliable signal (verified by live inspection)."""
|
|
return "/Rule" not in page.url
|
|
|
|
|
|
def _ensure_authenticated(page):
|
|
page.goto(f"{BASE_URL}/Rule/List")
|
|
if _is_logged_out(page):
|
|
raise AutomationError(
|
|
"MassPrice session expired or missing — log in via the "
|
|
"Session page (chrome://inspect) and click Capture Session."
|
|
)
|
|
|
|
|
|
def session_status():
|
|
if not os.path.exists(AUTH_STATE_PATH):
|
|
return {"state": "missing", "saved_at": None}
|
|
mtime = os.path.getmtime(AUTH_STATE_PATH)
|
|
saved_at = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat()
|
|
return {"state": "saved", "saved_at": saved_at}
|
|
|
|
|
|
def list_rules():
|
|
"""Scrape /Rule/List for current rule id/name pairs.
|
|
|
|
Returns a list of {"id": ..., "name": ...} dicts and caches it to
|
|
rules_cache.json.
|
|
"""
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True, args=LAUNCH_ARGS)
|
|
try:
|
|
context = _new_context(browser)
|
|
page = context.new_page()
|
|
_ensure_authenticated(page)
|
|
_save_state(context)
|
|
|
|
rows = page.locator("li.rule.widget-rule")
|
|
rules = []
|
|
for i in range(rows.count()):
|
|
row = rows.nth(i)
|
|
run_link = row.locator("a.run-rule").first
|
|
rule_id = run_link.get_attribute("data-rule-id")
|
|
if not rule_id:
|
|
continue
|
|
name_text = None
|
|
try:
|
|
# The rule name renders as the first line of text in
|
|
# the row, ahead of the "My Selling Prices" /
|
|
# "Schedule Active…" detail lines.
|
|
full_text = row.inner_text().strip()
|
|
name_text = full_text.splitlines()[0].strip()
|
|
except Exception:
|
|
pass
|
|
rules.append({"id": rule_id, "name": name_text or f"Rule {rule_id}"})
|
|
|
|
storage.save(
|
|
"rules_cache.json",
|
|
{"updated_at": datetime.now(timezone.utc).isoformat(), "rules": rules},
|
|
)
|
|
return rules
|
|
finally:
|
|
browser.close()
|
|
|
|
|
|
def run_rule(rule_id, rule_name=None):
|
|
"""Click "Run Rule" for the given rule id. Logs the outcome to
|
|
run_history.json and returns (status, message)."""
|
|
status = "failure"
|
|
message = ""
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True, args=LAUNCH_ARGS)
|
|
try:
|
|
context = _new_context(browser)
|
|
page = context.new_page()
|
|
_ensure_authenticated(page)
|
|
_save_state(context)
|
|
|
|
run_selector = f'a.run-rule[data-rule-id="{rule_id}"]'
|
|
row = page.locator("li.rule.widget-rule").filter(
|
|
has=page.locator(run_selector)
|
|
)
|
|
if row.count() == 0:
|
|
raise AutomationError(
|
|
f"Rule id {rule_id} not found on /Rule/List "
|
|
"(renamed or deleted on the MassPrice side?)"
|
|
)
|
|
|
|
row.first.locator(".dropdown-ellipsis").click()
|
|
run_link = row.first.locator(run_selector)
|
|
run_link.wait_for(state="visible", timeout=5000)
|
|
run_link.click()
|
|
page.wait_for_timeout(1500) # let the click's AJAX call fire
|
|
|
|
status = "success"
|
|
message = "Run Rule clicked"
|
|
except Exception as exc:
|
|
message = str(exc)
|
|
logger.exception("run_rule failed for rule %s", rule_id)
|
|
finally:
|
|
browser.close()
|
|
|
|
_log_history(rule_id, rule_name, status, message)
|
|
return status, message
|
|
|
|
|
|
def _log_history(rule_id, rule_name, status, message):
|
|
history = storage.load("run_history.json", [])
|
|
history.insert(
|
|
0,
|
|
{
|
|
"rule_id": rule_id,
|
|
"rule_name": rule_name,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"status": status,
|
|
"message": message,
|
|
},
|
|
)
|
|
storage.save("run_history.json", history[:200])
|
|
|
|
|
|
# ── Interactive login-session capture (Chrome remote debugging) ─────────
|
|
#
|
|
# Everything below runs exclusively on `_debug_worker`'s thread. Flask
|
|
# routes call start_debug_session()/capture_debug_session()/
|
|
# stop_debug_session(), which just enqueue a command and block for the
|
|
# result — never touch the Playwright objects directly.
|
|
|
|
_debug_cmd_queue: "queue.Queue" = queue.Queue()
|
|
_debug_thread = None
|
|
_debug_active = False # read from other threads; plain bool assignment is atomic in CPython
|
|
|
|
|
|
def _debug_worker():
|
|
global _debug_active
|
|
playwright = browser = context = page = timer = socat_proc = None
|
|
|
|
def do_stop():
|
|
nonlocal playwright, browser, context, page, timer, socat_proc
|
|
global _debug_active
|
|
if timer:
|
|
timer.cancel()
|
|
timer = None
|
|
if socat_proc:
|
|
try:
|
|
socat_proc.terminate()
|
|
except Exception:
|
|
pass
|
|
socat_proc = None
|
|
if browser:
|
|
try:
|
|
browser.close()
|
|
except Exception:
|
|
pass
|
|
if playwright:
|
|
try:
|
|
playwright.stop()
|
|
except Exception:
|
|
pass
|
|
playwright = browser = context = page = None
|
|
_debug_active = False
|
|
|
|
while True:
|
|
cmd, result_q = _debug_cmd_queue.get()
|
|
try:
|
|
if cmd == "start":
|
|
if browser is None:
|
|
playwright = sync_playwright().start()
|
|
browser = playwright.chromium.launch(
|
|
headless=True,
|
|
args=LAUNCH_ARGS
|
|
+ [
|
|
f"--remote-debugging-port={_INTERNAL_DEBUG_PORT}",
|
|
"--remote-allow-origins=*",
|
|
],
|
|
)
|
|
# Re-expose Chromium's loopback-only debug port on
|
|
# all interfaces so Docker's port mapping (which
|
|
# forwards to _PROXY_PORT, not Chromium's own port —
|
|
# see the comment above) can actually reach it.
|
|
socat_proc = subprocess.Popen(
|
|
[
|
|
"socat",
|
|
f"TCP-LISTEN:{_PROXY_PORT},fork,reuseaddr",
|
|
f"TCP:127.0.0.1:{_INTERNAL_DEBUG_PORT}",
|
|
]
|
|
)
|
|
context = _new_context(browser)
|
|
page = context.new_page()
|
|
page.goto(f"{BASE_URL}/")
|
|
_debug_active = True
|
|
timer = threading.Timer(
|
|
DEBUG_SESSION_TIMEOUT, lambda: _debug_cmd_queue.put(("stop", None))
|
|
)
|
|
timer.daemon = True
|
|
timer.start()
|
|
if result_q:
|
|
result_q.put(("ok", None))
|
|
elif cmd == "capture":
|
|
if context is None:
|
|
if result_q:
|
|
result_q.put(
|
|
("error", "No active login session — click Start Login Session first")
|
|
)
|
|
else:
|
|
page.goto(f"{BASE_URL}/Rule/List")
|
|
page.wait_for_timeout(1000)
|
|
if _is_logged_out(page):
|
|
if result_q:
|
|
result_q.put(
|
|
(
|
|
"error",
|
|
"Still not logged in — finish logging in via "
|
|
"chrome://inspect, then try Capture Session again",
|
|
)
|
|
)
|
|
else:
|
|
_save_state(context)
|
|
do_stop()
|
|
if result_q:
|
|
result_q.put(("ok", None))
|
|
elif cmd == "stop":
|
|
do_stop()
|
|
if result_q:
|
|
result_q.put(("ok", None))
|
|
except Exception as exc:
|
|
logger.exception("debug session command %r failed", cmd)
|
|
if result_q:
|
|
result_q.put(("error", str(exc)))
|
|
|
|
|
|
def _ensure_debug_thread():
|
|
global _debug_thread
|
|
if _debug_thread is None or not _debug_thread.is_alive():
|
|
_debug_thread = threading.Thread(target=_debug_worker, daemon=True)
|
|
_debug_thread.start()
|
|
|
|
|
|
def _debug_call(cmd, timeout=30):
|
|
result_q: "queue.Queue" = queue.Queue()
|
|
_debug_cmd_queue.put((cmd, result_q))
|
|
status, payload = result_q.get(timeout=timeout)
|
|
if status == "error":
|
|
raise AutomationError(payload)
|
|
|
|
|
|
def start_debug_session():
|
|
_ensure_debug_thread()
|
|
_debug_call("start")
|
|
|
|
|
|
def capture_debug_session():
|
|
_debug_call("capture")
|
|
|
|
|
|
def stop_debug_session():
|
|
_ensure_debug_thread()
|
|
_debug_call("stop")
|
|
|
|
|
|
def debug_session_active():
|
|
return _debug_active
|