Let the extension update itself, and host the dashboard
Chrome has no update channel for an unpacked extension, so every pull left one manual step: click reload on its card. The dashboard now publishes the version its own checkout ships at /api/autobuyer/status, and the extension reloads itself when it is behind — chrome.runtime.reload() re-reads the folder from disk, which the update check has already pulled into. It only ever runs code that is on disk, so a reload that changes nothing is reported and waited out rather than repeated; a dashboard that is behind is ignored. Also adds the deploy stack for running the dashboard on the droplet, next to the autotrader one: it joins that nginx's network and renders a server block into its conf.d rather than standing up a second ingress. AUTOFIRMER_DB moves the SQLite file onto a volume, since the working directory in a container is replaced on every deploy. And waitFor gained `hidden`: an absent gate matched on DOM presence only, so a modal that closes by going display:none could never satisfy it. The extension grew a visibility probe for this — locate cannot answer it, because absent and hidden are both errors there and a wait gate has to tell them apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1f4459985a
commit
d1e8668440
@@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-*
|
||||||
|
clicker/.venv
|
||||||
|
clicker/__pycache__
|
||||||
|
scripts/update.log
|
||||||
|
scripts/.last-check
|
||||||
|
scripts/*.png
|
||||||
|
.env*
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -63,3 +63,6 @@ scripts/update.log
|
|||||||
# clicker virtualenv (Linux/Pi)
|
# clicker virtualenv (Linux/Pi)
|
||||||
clicker/.venv/
|
clicker/.venv/
|
||||||
scripts/.last-check
|
scripts/.last-check
|
||||||
|
|
||||||
|
# The deploy template is meant to be read, unlike a real .env
|
||||||
|
!deploy/.env.example
|
||||||
|
|||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# The hosted dashboard only. The clicker cannot run in here — it drives a real
|
||||||
|
# mouse and keyboard and needs a desktop session — and neither can the
|
||||||
|
# extension. Both stay on the Windows machine and talk to this over HTTPS.
|
||||||
|
#
|
||||||
|
# Debian rather than Alpine: better-sqlite3 publishes glibc prebuilds, and a
|
||||||
|
# musl image would compile it from source on every build.
|
||||||
|
|
||||||
|
# ── deps ─────────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:24-slim AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
# Only used if npm cannot find a prebuilt better-sqlite3 for this Node ABI.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# ── prod deps ────────────────────────────────────────────────────────────────
|
||||||
|
# A separate, dev-free install for the runtime image.
|
||||||
|
FROM node:24-slim AS proddeps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# ── build ────────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:24-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── run ──────────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:24-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
PORT=3000 \
|
||||||
|
HOSTNAME=0.0.0.0 \
|
||||||
|
AUTOFIRMER_DB=/data/autotrader.sqlite
|
||||||
|
|
||||||
|
RUN groupadd -g 1001 nodejs && useradd -u 1001 -g nodejs -m nextjs
|
||||||
|
|
||||||
|
# The SQLite database lives here, on a named volume. Anything written inside the
|
||||||
|
# image is thrown away on the next deploy, and WAL mode also needs to create
|
||||||
|
# -wal and -shm files alongside the database, so the directory must be writable.
|
||||||
|
RUN mkdir -p /data && chown nextjs:nodejs /data
|
||||||
|
|
||||||
|
COPY --from=proddeps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||||
|
COPY --chown=nextjs:nodejs package.json next.config.ts ./
|
||||||
|
COPY --chown=nextjs:nodejs public ./public
|
||||||
|
# /api/autobuyer/status reads the version out of this manifest and the extension
|
||||||
|
# reloads itself to match, so the folder has to travel with the server.
|
||||||
|
COPY --chown=nextjs:nodejs extension ./extension
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:3000/api/autobuyer/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -131,9 +131,11 @@ pick it up from the server.
|
|||||||
`chrome://extensions` → enable **Developer mode** → **Load unpacked** →
|
`chrome://extensions` → enable **Developer mode** → **Load unpacked** →
|
||||||
select the `extension` folder.
|
select the `extension` folder.
|
||||||
|
|
||||||
Chrome does **not** reload an unpacked extension when its files change. After
|
Chrome does **not** reload an unpacked extension when its files change, and it
|
||||||
pulling updates, click the reload icon on its card — the dashboard shows the
|
has no update channel for one either. The extension therefore checks the version
|
||||||
version it sees, and a mismatch means the reload did not take.
|
the dashboard ships on every poll and reloads *itself* when it is behind, which
|
||||||
|
re-reads the folder from disk. Clicking the reload icon on its card still works
|
||||||
|
and is the thing to try when something looks stuck.
|
||||||
|
|
||||||
Adding a new firm also means adding its host to `host_permissions` in
|
Adding a new firm also means adding its host to `host_permissions` in
|
||||||
`extension/manifest.json` and reloading. Without it the extension cannot read
|
`extension/manifest.json` and reloading. Without it the extension cannot read
|
||||||
@@ -280,6 +282,25 @@ New-NetFirewallRule -DisplayName "AutoFirmer" -Direction Inbound -Protocol TCP -
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Hosting the dashboard on a server
|
||||||
|
|
||||||
|
The dashboard can run on the droplet instead of on the trading machine, with the
|
||||||
|
clicker and the extension pointing at it over HTTPS. The clicker and the
|
||||||
|
extension cannot move — one needs a desktop to send real input to, the other is
|
||||||
|
your browser.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp deploy/.env.example deploy/.env # set FIRMER_HOST
|
||||||
|
./deploy/bootstrap-tls.sh # first run: certificate, then deploy
|
||||||
|
./deploy/deploy.sh # every run after that
|
||||||
|
```
|
||||||
|
|
||||||
|
It shares the droplet's existing nginx and certbot rather than standing up its
|
||||||
|
own, and the database lives on a Docker volume. There is no login on it. See
|
||||||
|
[deploy/README.md](deploy/README.md) before opening it to the internet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
Once auto-start is installed, nothing here is manual. Every 5 minutes the update
|
Once auto-start is installed, nothing here is manual. Every 5 minutes the update
|
||||||
@@ -318,11 +339,19 @@ To apply an update immediately rather than waiting for the next check:
|
|||||||
node scripts\update-check.mjs
|
node scripts\update-check.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
### The two things that still do not reload themselves
|
### What reloads itself, and what does not
|
||||||
|
|
||||||
- **The extension** — click reload on its card in `chrome://extensions`.
|
- **The extension** reloads itself now. `/api/autobuyer/status` reports the
|
||||||
|
version the dashboard's own checkout ships; when the running build is older,
|
||||||
|
the extension calls `chrome.runtime.reload()` and comes back on the files the
|
||||||
|
update check pulled. It only ever runs code already on disk, so if it reloads
|
||||||
|
before the pull lands it says so in the popup and waits rather than restarting
|
||||||
|
in a loop. Clicking reload on its card is still the manual fallback.
|
||||||
- **The scheduler is fine now.** It persists to the settings table and resumes
|
- **The scheduler is fine now.** It persists to the settings table and resumes
|
||||||
after a restart, so an update no longer silently stops automated trading.
|
after a restart, so an update no longer silently stops automated trading.
|
||||||
|
- **The runner** does not. It is a long-lived Python process; the update check
|
||||||
|
restarts it when anything under `clicker/` changes, and the dashboard warns
|
||||||
|
when the version it reports is behind what the server expects.
|
||||||
|
|
||||||
The dashboard reports the version it sees from the extension and the runner, and
|
The dashboard reports the version it sees from the extension and the runner, and
|
||||||
warns when either is behind. Most AutoBuyer bugs that look mysterious are the
|
warns when either is behind. Most AutoBuyer bugs that look mysterious are the
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { NextResponse } from 'next/server';
|
|||||||
|
|
||||||
/** The Chromium extension calls these routes from a background service worker,
|
/** The Chromium extension calls these routes from a background service worker,
|
||||||
* whose Origin is `chrome-extension://<id>`. The id changes every time the
|
* whose Origin is `chrome-extension://<id>`. The id changes every time the
|
||||||
* unpacked extension is reloaded, so we allow any origin — these routes are
|
* unpacked extension is reloaded, so we allow any origin.
|
||||||
* only ever reachable on the LAN behind the port-3000 firewall rule. */
|
*
|
||||||
|
* That was harmless while this only ever answered on the LAN behind the
|
||||||
|
* port-3000 firewall rule. The hosted dashboard has no such fence — see
|
||||||
|
* deploy/README.md, "No login". */
|
||||||
export const CORS_HEADERS = {
|
export const CORS_HEADERS = {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
|
import { readFileSync, statSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import { countPendingLocate } from '@/lib/db';
|
import { countPendingLocate } from '@/lib/db';
|
||||||
import { corsJson, corsPreflight } from '../cors';
|
import { corsJson, corsPreflight } from '../cors';
|
||||||
|
|
||||||
|
/** The extension version this checkout ships.
|
||||||
|
*
|
||||||
|
* Read out of the manifest rather than kept as a constant here: the manifest is
|
||||||
|
* the file Chrome actually loads, so the two cannot drift apart. Cached on
|
||||||
|
* mtime because every browser polls this every few seconds and the file only
|
||||||
|
* changes when an update lands. Null when there is no extension folder beside
|
||||||
|
* the server — the extension then leaves itself alone, which is the right
|
||||||
|
* answer for a dashboard that cannot see what it is supposed to be running. */
|
||||||
|
let manifestCache: { mtimeMs: number; version: string | null } | null = null;
|
||||||
|
|
||||||
|
function shippedExtensionVersion(): string | null {
|
||||||
|
const file = path.join(process.cwd(), 'extension', 'manifest.json');
|
||||||
|
try {
|
||||||
|
const { mtimeMs } = statSync(file);
|
||||||
|
if (!manifestCache || manifestCache.mtimeMs !== mtimeMs) {
|
||||||
|
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { version?: string };
|
||||||
|
manifestCache = { mtimeMs, version: parsed.version ?? null };
|
||||||
|
}
|
||||||
|
return manifestCache.version;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Polled by the Chromium extension every few seconds, and by the AutoBuyer page.
|
/** Polled by the Chromium extension every few seconds, and by the AutoBuyer page.
|
||||||
* The AutoBuyer has no on/off switch — it is ready whenever the runner is — so
|
* The AutoBuyer has no on/off switch — it is ready whenever the runner is — so
|
||||||
* the only thing worth reporting here is whether clicks are waiting to be served. */
|
* the only things worth reporting here are whether clicks are waiting to be
|
||||||
|
* served, and which extension build this checkout expects to be talking to. */
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return corsJson({ pendingLocate: countPendingLocate() });
|
return corsJson({
|
||||||
|
pendingLocate: countPendingLocate(),
|
||||||
|
extensionVersion: shippedExtensionVersion(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function OPTIONS() {
|
export async function OPTIONS() {
|
||||||
|
|||||||
+25
-6
@@ -34,7 +34,7 @@ HEARTBEAT_SECONDS = 2.0
|
|||||||
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
|
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
|
||||||
# the heartbeat so the dashboard can say "restart your runner" instead of letting
|
# the heartbeat so the dashboard can say "restart your runner" instead of letting
|
||||||
# a stale process fail on a step type it has never heard of.
|
# a stale process fail on a step type it has never heard of.
|
||||||
VERSION = "0.17.0"
|
VERSION = "0.18.0"
|
||||||
|
|
||||||
# Shared with the heartbeat thread: whether a run is currently executing.
|
# Shared with the heartbeat thread: whether a run is currently executing.
|
||||||
_busy = threading.Event()
|
_busy = threading.Event()
|
||||||
@@ -80,9 +80,14 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
|
|||||||
# `absent`, until it is gone. Nothing is clicked or typed. Whatever has to
|
# `absent`, until it is gone. Nothing is clicked or typed. Whatever has to
|
||||||
# change the page — a person solving a challenge, a modal closing itself,
|
# change the page — a person solving a challenge, a modal closing itself,
|
||||||
# a slow server — happens outside this run.
|
# a slow server — happens outside this run.
|
||||||
|
#
|
||||||
|
# `hidden` widens an absent gate from "removed from the DOM" to "no
|
||||||
|
# longer visible": a modal that closes by going display:none never stops
|
||||||
|
# matching its selector, so the plain gate can only ever time out.
|
||||||
selector = step["selector"]
|
selector = step["selector"]
|
||||||
absent = bool(step.get("absent"))
|
absent = bool(step.get("absent"))
|
||||||
goal = "disappear" if absent else "appear"
|
by_sight = absent and bool(step.get("hidden"))
|
||||||
|
goal = "disappear from view" if by_sight else "disappear" if absent else "appear"
|
||||||
timeout_s = float(step.get("timeoutSeconds", 120))
|
timeout_s = float(step.get("timeoutSeconds", 120))
|
||||||
deadline = time.time() + timeout_s
|
deadline = time.time() + timeout_s
|
||||||
announced = False
|
announced = False
|
||||||
@@ -93,10 +98,24 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
|
|||||||
raise actions.StepError("cancelled while waiting")
|
raise actions.StepError("cancelled while waiting")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dash.locate(selector, int(step.get("index", 0)),
|
seen = dash.locate(selector, int(step.get("index", 0)),
|
||||||
step.get("urlPattern", "") or opts.url,
|
step.get("urlPattern", "") or opts.url,
|
||||||
opts.timeout, step.get("openUrl", "") or "")
|
opts.timeout, step.get("openUrl", "") or "",
|
||||||
if not absent:
|
options={"op": "visible"} if by_sight else None)
|
||||||
|
if by_sight:
|
||||||
|
# An extension too old for the probe quietly does an ordinary
|
||||||
|
# locate instead, whose result has no `visible` key — reading
|
||||||
|
# that as "gone" would wave the gate through on a visible
|
||||||
|
# element, so insist the answer is the one we asked for.
|
||||||
|
if seen.get("op") != "visible":
|
||||||
|
raise actions.StepError(
|
||||||
|
"waitFor `hidden` needs AutoBuyer extension 0.12.0+ — "
|
||||||
|
"reload it in chrome://extensions"
|
||||||
|
)
|
||||||
|
if not seen.get("visible"):
|
||||||
|
report(f"{selector} is out of sight ({seen.get('reason') or 'hidden'})")
|
||||||
|
break
|
||||||
|
elif not absent:
|
||||||
report(f"{selector} appeared")
|
report(f"{selector} appeared")
|
||||||
break
|
break
|
||||||
# Still there. Keep waiting for it to go.
|
# Still there. Keep waiting for it to go.
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# The hostname the dashboard answers on. Needs an A record pointing at the
|
||||||
|
# droplet before bootstrap-tls.sh can get a certificate for it.
|
||||||
|
FIRMER_HOST=firmer.example.com
|
||||||
|
|
||||||
|
# Where the autotrader stack lives. Its nginx is the shared ingress, its certbot
|
||||||
|
# issues the certificates, and its compose project owns the network this joins.
|
||||||
|
SHARED_STACK=/opt/autotrader/deploy
|
||||||
|
|
||||||
|
# The docker network that stack created. Only set this if you renamed the
|
||||||
|
# project — the default matches `docker network ls`.
|
||||||
|
SHARED_NETWORK=autotrader-vps_web
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Hosting the dashboard on the droplet
|
||||||
|
|
||||||
|
The dashboard is the only piece that can live on a server. The clicker drives a
|
||||||
|
real mouse and keyboard and the extension runs inside your Chrome, so both stay
|
||||||
|
on the Windows machine — they just point at the droplet instead of at
|
||||||
|
`localhost`.
|
||||||
|
|
||||||
|
```
|
||||||
|
Windows box 159.223.129.19
|
||||||
|
clicker (runner.py) ──────┐
|
||||||
|
Chrome + extension ───────┴──HTTPS──> nginx ──> autofirmer:3000 ──> /data/autotrader.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
This stack deliberately owns none of the ingress. The droplet already runs one
|
||||||
|
nginx on 80/443 for `master.juicerroom.com` and `git.juicerroom.com`, from
|
||||||
|
`/opt/autotrader/deploy`; this joins that network, and `deploy.sh` renders a
|
||||||
|
server block into that stack's `nginx/conf.d`. Two consequences worth knowing:
|
||||||
|
|
||||||
|
- The rendered `firmer.conf` is untracked in the autotrader repo. It survives
|
||||||
|
that stack's pulls and deploys. It does **not** survive its
|
||||||
|
`bootstrap-tls.sh`, which clears `conf.d` — re-run `./deploy.sh` here if that
|
||||||
|
ever happens.
|
||||||
|
- `deploy.sh` reloads the shared nginx, so a mistake in the template would be a
|
||||||
|
mistake in front of the autotrader dashboard too. It runs `nginx -t` first and
|
||||||
|
refuses to reload on a bad config, and it will not install the TLS server
|
||||||
|
block until the certificate actually exists on disk.
|
||||||
|
|
||||||
|
## First run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # set FIRMER_HOST
|
||||||
|
./bootstrap-tls.sh # DNS check, certificate, then deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`bootstrap-tls.sh` uses the autotrader stack's certbot container and Let's
|
||||||
|
Encrypt account, and reads `ACME_EMAIL` from its `.env` rather than keeping a
|
||||||
|
second copy. The A record has to point at the droplet first, or the challenge
|
||||||
|
cannot be answered.
|
||||||
|
|
||||||
|
## Shipping a change
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull && ./deploy/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The build runs before anything is swapped, so a broken commit leaves the running
|
||||||
|
container serving. The database is on the `autofirmer-data` volume and is never
|
||||||
|
touched by a deploy.
|
||||||
|
|
||||||
|
## Pointing the desktop at it
|
||||||
|
|
||||||
|
- **Extension** — open its popup and set the dashboard URL to
|
||||||
|
`https://<FIRMER_HOST>`.
|
||||||
|
- **Clicker** — `python runner.py --api https://<FIRMER_HOST>`, or edit the
|
||||||
|
argument in the PM2 config that starts it.
|
||||||
|
|
||||||
|
Both keep working against `http://localhost:3000` instead; nothing here forces
|
||||||
|
the hosted one.
|
||||||
|
|
||||||
|
## No login
|
||||||
|
|
||||||
|
There is none, at the owner's request, and basic auth could not be bolted on
|
||||||
|
without breaking the two clients: the extension and the clicker poll
|
||||||
|
`/api/autobuyer/*` unattended with nowhere to put a password. Anyone who reaches
|
||||||
|
the host can queue runs, read the settings table — the 2captcha key included —
|
||||||
|
and spend money. If that changes, the options are an IP allowlist in the nginx
|
||||||
|
template, or a shared secret that both clients learn to send.
|
||||||
|
|
||||||
|
## Updating the extension
|
||||||
|
|
||||||
|
The extension can no longer be updated by this stack alone, and it is worth
|
||||||
|
being clear about why. Chrome has no update channel for an unpacked extension:
|
||||||
|
it reads the folder off disk once and never looks again. What the dashboard
|
||||||
|
does is *publish* the version its own checkout ships, at
|
||||||
|
`/api/autobuyer/status`. The extension compares that against its own manifest
|
||||||
|
and calls `chrome.runtime.reload()`, which re-reads the folder from disk.
|
||||||
|
|
||||||
|
So the files still arrive the way they always did — `scripts/update-check.mjs`
|
||||||
|
pulling on the Windows box every five minutes. If the droplet is ahead of that
|
||||||
|
pull, the extension reloads once, notices nothing changed, and waits instead of
|
||||||
|
looping; the popup says as much.
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# First run only: get a certificate for FIRMER_HOST, then hand off to deploy.sh.
|
||||||
|
#
|
||||||
|
# This uses the autotrader stack's certbot container and its Let's Encrypt
|
||||||
|
# account, so it agrees to the Subscriber Agreement the same way that stack's
|
||||||
|
# own bootstrap did (certbot --agree-tos):
|
||||||
|
# https://letsencrypt.org/repository/
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
log() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
|
||||||
|
fail() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$1" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ -f .env ] || fail "no .env — copy .env.example to .env and fill it in"
|
||||||
|
set -a; . ./.env; set +a
|
||||||
|
: "${FIRMER_HOST:?set FIRMER_HOST in .env}"
|
||||||
|
SHARED_STACK="${SHARED_STACK:-/opt/autotrader/deploy}"
|
||||||
|
|
||||||
|
# ACME_EMAIL is not duplicated here: it belongs to the account that already
|
||||||
|
# holds the other certificates on this box.
|
||||||
|
[ -f "$SHARED_STACK/.env" ] || fail "no .env under $SHARED_STACK"
|
||||||
|
ACME_EMAIL=$(grep -E '^ACME_EMAIL=' "$SHARED_STACK/.env" | cut -d= -f2-)
|
||||||
|
[ -n "$ACME_EMAIL" ] || fail "ACME_EMAIL is not set in $SHARED_STACK/.env"
|
||||||
|
|
||||||
|
# Let's Encrypt rate-limits failures, so check DNS before spending an attempt.
|
||||||
|
log "Checking DNS"
|
||||||
|
ip=$(curl -fsS --max-time 5 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null \
|
||||||
|
|| curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null) \
|
||||||
|
|| fail "could not determine this droplet's public IP"
|
||||||
|
got=$(getent ahostsv4 "$FIRMER_HOST" 2>/dev/null | awk 'NR==1{print $1}') || true
|
||||||
|
echo " droplet: $ip"
|
||||||
|
echo " $FIRMER_HOST -> ${got:-<unresolved>}"
|
||||||
|
[ "$got" = "$ip" ] || fail "$FIRMER_HOST does not resolve to $ip — fix DNS and wait for it to propagate"
|
||||||
|
|
||||||
|
# The challenge is served over plain HTTP by the shared nginx, which needs a
|
||||||
|
# server block for this name before it will answer for it.
|
||||||
|
log "Rendering the HTTP-only config so the challenge can be served"
|
||||||
|
envsubst '${FIRMER_HOST}' < nginx/firmer-acme.conf.template > "$SHARED_STACK/nginx/conf.d/firmer.conf"
|
||||||
|
docker exec nginx nginx -t
|
||||||
|
docker exec nginx nginx -s reload
|
||||||
|
|
||||||
|
log "Requesting a certificate for $FIRMER_HOST"
|
||||||
|
docker compose --project-directory "$SHARED_STACK" -f "$SHARED_STACK/docker-compose.yml" \
|
||||||
|
run --rm --entrypoint certbot certbot \
|
||||||
|
certonly --webroot -w /var/www/certbot \
|
||||||
|
--email "$ACME_EMAIL" --agree-tos --no-eff-email \
|
||||||
|
--non-interactive -d "$FIRMER_HOST" \
|
||||||
|
|| fail "certbot failed — check DNS and that port 80 reaches this droplet"
|
||||||
|
|
||||||
|
log "Switching to the TLS config"
|
||||||
|
exec ./deploy.sh
|
||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rebuild the dashboard image, restart the container, point the shared nginx at
|
||||||
|
# it. Safe to re-run any time — this is the normal "ship a change" command.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
log() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
|
||||||
|
fail() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$1" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ -f .env ] || fail "no .env — copy .env.example to .env and fill it in"
|
||||||
|
set -a; . ./.env; set +a
|
||||||
|
: "${FIRMER_HOST:?set FIRMER_HOST in .env}"
|
||||||
|
SHARED_STACK="${SHARED_STACK:-/opt/autotrader/deploy}"
|
||||||
|
|
||||||
|
command -v envsubst >/dev/null || fail "envsubst missing — apt-get install -y gettext-base"
|
||||||
|
[ -d "$SHARED_STACK/nginx/conf.d" ] || fail "no nginx/conf.d under $SHARED_STACK"
|
||||||
|
|
||||||
|
log "Building and starting"
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
log "Waiting for the container to report healthy"
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
state=$(docker inspect autofirmer --format '{{.State.Health.Status}}' 2>/dev/null || echo missing)
|
||||||
|
[ "$state" = healthy ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ "${state:-}" = healthy ] || fail "autofirmer is $state — check: docker logs autofirmer"
|
||||||
|
|
||||||
|
# Rendered into the *other* stack's conf.d, because that is the directory its
|
||||||
|
# nginx has mounted. The file is untracked there, and survives that stack's
|
||||||
|
# deploys and pulls; only its bootstrap-tls.sh wipes conf.d, and re-running this
|
||||||
|
# script puts it back.
|
||||||
|
#
|
||||||
|
# Held back until the certificate exists: nginx refuses to load a server block
|
||||||
|
# whose ssl_certificate is missing, and a broken file in that directory would
|
||||||
|
# also abort the autotrader deploys that reload the same nginx.
|
||||||
|
if docker run --rm -v autotrader-vps_letsencrypt:/le alpine \
|
||||||
|
test -f "/le/live/$FIRMER_HOST/fullchain.pem" 2>/dev/null; then
|
||||||
|
template=nginx/firmer.conf.template
|
||||||
|
else
|
||||||
|
log "No certificate for $FIRMER_HOST yet — rendering the HTTP-only config"
|
||||||
|
log "Run ./bootstrap-tls.sh once DNS points here, then this script again"
|
||||||
|
template=nginx/firmer-acme.conf.template
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Rendering $template"
|
||||||
|
# Named explicitly so envsubst leaves nginx's own $host, $remote_addr and the
|
||||||
|
# rest alone.
|
||||||
|
envsubst '${FIRMER_HOST}' < "$template" > "$SHARED_STACK/nginx/conf.d/firmer.conf"
|
||||||
|
|
||||||
|
log "Reloading the shared nginx"
|
||||||
|
docker exec nginx nginx -t
|
||||||
|
docker exec nginx nginx -s reload
|
||||||
|
|
||||||
|
log "Status"
|
||||||
|
docker compose ps
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# The dashboard, alongside the autotrader stack rather than instead of it: the
|
||||||
|
# droplet already runs one nginx on 80/443, and this joins that network so the
|
||||||
|
# same ingress can proxy to it. Nothing here publishes a host port.
|
||||||
|
name: autofirmer
|
||||||
|
|
||||||
|
services:
|
||||||
|
autofirmer:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: autofirmer:local
|
||||||
|
container_name: autofirmer
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
AUTOFIRMER_DB: /data/autotrader.sqlite
|
||||||
|
volumes:
|
||||||
|
- autofirmer-data:/data
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
networks: [web]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# Created by the autotrader stack. `external` so this compose file never tries
|
||||||
|
# to own it — bringing this stack down must not take the ingress with it.
|
||||||
|
web:
|
||||||
|
name: ${SHARED_NETWORK:-autotrader-vps_web}
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
autofirmer-data:
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# HTTP only, for the window before a certificate exists: nginx will not start
|
||||||
|
# with an ssl_certificate that is not on disk yet, so the real config cannot go
|
||||||
|
# in until certbot has run. Rendered by bootstrap-tls.sh.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name ${FIRMER_HOST};
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# AutoFirmer dashboard — rendered into the autotrader stack's nginx/conf.d by
|
||||||
|
# deploy.sh. Edit the template, not the rendered file.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name ${FIRMER_HOST};
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name ${FIRMER_HOST};
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/${FIRMER_HOST}/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/${FIRMER_HOST}/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
|
||||||
|
# A capture posts a whole page's HTML, which runs to megabytes on the firm
|
||||||
|
# dashboards. nginx's 1m default would reject those with a 413.
|
||||||
|
client_max_body_size 32m;
|
||||||
|
|
||||||
|
# No basic auth, at the owner's request and because it could not work here
|
||||||
|
# anyway: the extension and the clicker poll /api/autobuyer/* unattended and
|
||||||
|
# have nowhere to put a password. Anyone who reaches this host can queue
|
||||||
|
# runs, read the settings table including the 2captcha key, and spend money.
|
||||||
|
location / {
|
||||||
|
proxy_pass http://autofirmer:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
-1
@@ -255,6 +255,50 @@ function pageLocate(selector, index) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Runs in the page. Answers "is this element there, and can it be seen?" —
|
||||||
|
* no scrolling, no measuring, no error when it is missing. pageLocate cannot
|
||||||
|
* answer this: absent and hidden both come back as errors there, and a wait
|
||||||
|
* gate has to tell those two apart from a broken pipeline. */
|
||||||
|
function pageVisible(selector, index) {
|
||||||
|
const matches = document.querySelectorAll(selector);
|
||||||
|
const el = matches[index];
|
||||||
|
if (!el) {
|
||||||
|
return {
|
||||||
|
op: 'visible', present: false, visible: false, matchCount: matches.length,
|
||||||
|
reason: matches.length ? `matched ${matches.length} element(s), no index ${index}` : 'no element matches',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkVisibility covers what CSS can do to make an element invisible while
|
||||||
|
// leaving it in the DOM — display, visibility, content-visibility, opacity —
|
||||||
|
// including when the property sits on an ancestor. Both spellings of the
|
||||||
|
// options are passed: Chrome renamed them, and unknown keys are ignored.
|
||||||
|
const rendered = typeof el.checkVisibility === 'function'
|
||||||
|
? el.checkVisibility({
|
||||||
|
checkVisibilityCSS: true, checkOpacity: true,
|
||||||
|
visibilityProperty: true, opacityProperty: true,
|
||||||
|
contentVisibilityAuto: true,
|
||||||
|
})
|
||||||
|
: true;
|
||||||
|
|
||||||
|
// Size is not part of that check, and a container collapsed to nothing is
|
||||||
|
// just as invisible as one that was hidden outright.
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const sized = r.width > 0 && r.height > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: 'visible',
|
||||||
|
present: true,
|
||||||
|
visible: rendered && sized,
|
||||||
|
matchCount: matches.length,
|
||||||
|
tag: el.tagName.toLowerCase(),
|
||||||
|
text: (el.textContent || '').trim().slice(0, 80),
|
||||||
|
viewport: { x: r.left, y: r.top, width: r.width, height: r.height },
|
||||||
|
reason: rendered ? (sized ? null : 'zero size') : 'hidden by CSS',
|
||||||
|
url: location.href,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Is the tab already showing this page? Compares origin and path only —
|
/** Is the tab already showing this page? Compares origin and path only —
|
||||||
* query strings and hashes shouldn't force a reload. */
|
* query strings and hashes shouldn't force a reload. */
|
||||||
function alreadyAt(current, target) {
|
function alreadyAt(current, target) {
|
||||||
@@ -714,6 +758,23 @@ async function serveLocateRequest(cfg) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A visibility probe is a read, not a lookup: it reports on the element
|
||||||
|
// instead of failing when there isn't one, so it never retries and never
|
||||||
|
// errors on a missing selector — being missing is the answer.
|
||||||
|
if (request.options && request.options.op === 'visible') {
|
||||||
|
const [probe] = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
func: pageVisible,
|
||||||
|
args: [request.selector, request.index || 0],
|
||||||
|
});
|
||||||
|
const out = probe?.result;
|
||||||
|
if (!out) throw new Error('Visibility injection returned nothing');
|
||||||
|
result = { ...out, tabId: tab.id, windowId: tab.windowId, browser: detectBrowser() };
|
||||||
|
|
||||||
|
await postLocateResult(cfg, request.id, result, null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// `complete` only means the document loaded — a React app still has to
|
// `complete` only means the document loaded — a React app still has to
|
||||||
// mount and paint. Retry briefly rather than declaring the element missing,
|
// mount and paint. Retry briefly rather than declaring the element missing,
|
||||||
// with a longer budget when we just opened the page from cold.
|
// with a longer budget when we just opened the page from cold.
|
||||||
@@ -749,10 +810,11 @@ async function serveLocateRequest(cfg) {
|
|||||||
async function tick() {
|
async function tick() {
|
||||||
const cfg = await getConfig();
|
const cfg = await getConfig();
|
||||||
|
|
||||||
|
let status;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${cfg.apiBase}/api/autobuyer/status`, { cache: 'no-store' });
|
const res = await fetch(`${cfg.apiBase}/api/autobuyer/status`, { cache: 'no-store' });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const status = await res.json();
|
status = await res.json();
|
||||||
pendingLocate = Number(status.pendingLocate) || 0;
|
pendingLocate = Number(status.pendingLocate) || 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setBadge(false);
|
setBadge(false);
|
||||||
@@ -763,6 +825,16 @@ async function tick() {
|
|||||||
setBadge(true);
|
setBadge(true);
|
||||||
await setState({ connected: true });
|
await setState({ connected: true });
|
||||||
|
|
||||||
|
// Only when nothing is queued: a reload mid-run drops the locate the runner
|
||||||
|
// is blocked on. There is no hurry — the next tick is seconds away.
|
||||||
|
if (!pendingLocate) {
|
||||||
|
try {
|
||||||
|
await maybeSelfUpdate(status.extensionVersion);
|
||||||
|
} catch (err) {
|
||||||
|
await setState({ connected: true, error: `Self-update check: ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Serve queued clicks before capturing — the Python side is waiting on these,
|
// Serve queued clicks before capturing — the Python side is waiting on these,
|
||||||
// and a capture of a megabyte page shouldn't sit in front of them.
|
// and a capture of a megabyte page shouldn't sit in front of them.
|
||||||
// Failures inside a request are reported back to the caller; a failure of the
|
// Failures inside a request are reported back to the caller; a failure of the
|
||||||
@@ -782,6 +854,61 @@ async function tick() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Keeping ourselves current ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** -1 / 0 / 1 over dotted numeric versions; missing parts count as zero. */
|
||||||
|
function compareVersions(a, b) {
|
||||||
|
const pa = String(a).split('.').map(Number);
|
||||||
|
const pb = String(b).split('.').map(Number);
|
||||||
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||||
|
const diff = (pa[i] || 0) - (pb[i] || 0);
|
||||||
|
if (diff) return diff < 0 ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload ourselves when the dashboard ships a newer build than the one running.
|
||||||
|
*
|
||||||
|
* Chrome has no update channel for an unpacked extension — it reads the folder
|
||||||
|
* once at startup and never looks again — but chrome.runtime.reload() does
|
||||||
|
* re-read it from disk, and the updater is already pulling new files into that
|
||||||
|
* folder every few minutes. This closes the last manual step between the two.
|
||||||
|
*
|
||||||
|
* It can only ever run code that is already on disk, so a dashboard that is
|
||||||
|
* ahead of this machine's pull would have us reload into the same version over
|
||||||
|
* and over. Hence one attempt per version pair: if we come back unchanged, say
|
||||||
|
* so and wait for the pull instead of restarting forever. A dashboard that is
|
||||||
|
* *behind* is left alone entirely — downgrading is not something we can do. */
|
||||||
|
async function maybeSelfUpdate(expected) {
|
||||||
|
const running = chrome.runtime.getManifest().version;
|
||||||
|
const { updateAttempt } = await chrome.storage.local.get('updateAttempt');
|
||||||
|
|
||||||
|
if (!expected || compareVersions(expected, running) <= 0) {
|
||||||
|
// Nothing to do — and if we got here off the back of a reload, that
|
||||||
|
// reload worked, so clear the note it left in the popup.
|
||||||
|
if (updateAttempt) {
|
||||||
|
await chrome.storage.local.remove('updateAttempt');
|
||||||
|
await setState({ error: '' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateAttempt && updateAttempt.expected === expected && updateAttempt.running === running) {
|
||||||
|
await setState({
|
||||||
|
error: `Running v${running}, the dashboard ships v${expected}. Reloaded once `
|
||||||
|
+ `and the files on disk were still the old ones — waiting for the next `
|
||||||
|
+ `update. If this sticks, pull and reload the card in chrome://extensions.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recorded *before* the reload: the worker does not come back from this call,
|
||||||
|
// so anything written after it would never be written at all.
|
||||||
|
await chrome.storage.local.set({ updateAttempt: { expected, running, at: Date.now() } });
|
||||||
|
await setState({ error: `Reloading to pick up v${expected} (running v${running})` });
|
||||||
|
chrome.runtime.reload();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scheduling ──────────────────────────────────────────────────────────────
|
// ── Scheduling ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function restart() {
|
async function restart() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "AutoFirmer Capture",
|
"name": "AutoFirmer Capture",
|
||||||
"version": "0.11.2",
|
"version": "0.12.0",
|
||||||
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.",
|
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.",
|
||||||
"permissions": ["scripting", "tabs", "storage", "alarms"],
|
"permissions": ["scripting", "tabs", "storage", "alarms"],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
|
|||||||
+27
-9
@@ -23,10 +23,16 @@ export type AutomationStep =
|
|||||||
| { action: 'navigate'; url?: string; label?: string }
|
| { action: 'navigate'; url?: string; label?: string }
|
||||||
// Block until `selector` exists — or, with `absent`, until it is gone from the
|
// Block until `selector` exists — or, with `absent`, until it is gone from the
|
||||||
// DOM. Nothing is clicked or typed; this is a gate, for conditions something
|
// DOM. Nothing is clicked or typed; this is a gate, for conditions something
|
||||||
// outside the run has to satisfy. Note `absent` means removed, not merely
|
// outside the run has to satisfy. Note `absent` alone means removed, not
|
||||||
// hidden: an element still in the DOM with display:none keeps matching, so
|
// merely hidden: an element still in the DOM with display:none keeps
|
||||||
// for those use a selector that only matches while it is visible.
|
// matching, and the gate would sit there until it timed out.
|
||||||
| { action: 'waitFor'; selector: string; index?: number; absent?: boolean; timeoutSeconds?: number; label?: string }
|
//
|
||||||
|
// `hidden` (only meaningful with `absent`) widens the gate to "we cannot see
|
||||||
|
// it any more" — removed, display:none, visibility:hidden, opacity:0,
|
||||||
|
// content-visibility, or collapsed to zero size all end the wait. It says
|
||||||
|
// nothing about being scrolled off-screen or covered by an overlay: those
|
||||||
|
// are still on the page. Needs extension 0.12.0+ and runner 0.18.0+.
|
||||||
|
| { action: 'waitFor'; selector: string; index?: number; absent?: boolean; hidden?: boolean; timeoutSeconds?: number; label?: string }
|
||||||
// Scroll until the page stops adding elements matching `selector`, for lists
|
// Scroll until the page stops adding elements matching `selector`, for lists
|
||||||
// that load progressively. `containerSelector` names the scrolling element
|
// that load progressively. `containerSelector` names the scrolling element
|
||||||
// when the automatic guess is wrong — these lists usually scroll inside a div
|
// when the automatic guess is wrong — these lists usually scroll inside a div
|
||||||
@@ -171,7 +177,7 @@ export const FIRMS: Firm[] = [
|
|||||||
{ action: 'type', selector: 'div.summary_section div.MuiTextField-root input', text: 'MX8'},
|
{ action: 'type', selector: 'div.summary_section div.MuiTextField-root input', text: 'MX8'},
|
||||||
{ action: 'click', selector: 'div.summary_section div.MuiTextField-root + button'},
|
{ action: 'click', selector: 'div.summary_section div.MuiTextField-root + button'},
|
||||||
{ action: 'solveCaptcha', timeoutSeconds: 180, label: 'Solve the challenge' },
|
{ action: 'solveCaptcha', timeoutSeconds: 180, label: 'Solve the challenge' },
|
||||||
{ action: 'click', selector: 'div.summary_section > button:last-child', label: 'Open Add Account' },
|
{ action: 'click', selector: 'div.summary_section > button:last-child', label: 'Buy Account' },
|
||||||
// TODO: the rest of the purchase flow. Confirm each selector with
|
// TODO: the rest of the purchase flow. Confirm each selector with
|
||||||
// `clicker.py locate` before adding it here.
|
// `clicker.py locate` before adding it here.
|
||||||
|
|
||||||
@@ -249,9 +255,20 @@ export const FIRMS: Firm[] = [
|
|||||||
// dashboard, so go there first rather than assuming.
|
// dashboard, so go there first rather than assuming.
|
||||||
{ action: 'navigate', label: 'Open the Lucid dashboard' },
|
{ action: 'navigate', label: 'Open the Lucid dashboard' },
|
||||||
{ action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' },
|
{ action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' },
|
||||||
|
{ action: 'click', selector: 'button.tab-flex', label: 'Open Flex Accounts' },
|
||||||
|
|
||||||
{ action: 'repeat', timesFrom: 'count', steps: [
|
{ action: 'repeat', timesFrom: 'count', steps: [
|
||||||
// one purchase — the steps you already have
|
// one purchase — the steps you already have
|
||||||
{ action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' },
|
{ action: 'click', selector: 'input.coupon_code_field', label: 'Click on coupon code'},
|
||||||
|
{ action: 'type', selector: 'input.coupon_code_field', text: 'THEPROPFARM'},
|
||||||
|
{ action: 'click', selector: '.apply_coupon_button'},
|
||||||
|
{ action: 'click', selector: '.validate-required:not(.woocommerce-validated)', label: 'Agree to terms' },
|
||||||
|
{ action: 'click', selector: '.validate-required:not(.woocommerce-validated)', label: 'Agree to terms 2' },
|
||||||
|
|
||||||
|
{ action: 'waitFor', selector: '#woocommerceModal', absent: true, hidden: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
]},
|
]},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -405,9 +422,10 @@ export function describeStep(step: AutomationStep): string {
|
|||||||
case 'wait': return `wait ${step.seconds}s`;
|
case 'wait': return `wait ${step.seconds}s`;
|
||||||
case 'navigate': return `open ${step.url ?? 'the firm page'}`;
|
case 'navigate': return `open ${step.url ?? 'the firm page'}`;
|
||||||
case 'waitFor':
|
case 'waitFor':
|
||||||
return step.absent
|
if (!step.absent) return `wait for ${step.selector}`;
|
||||||
? `wait for ${step.selector} to disappear`
|
return step.hidden
|
||||||
: `wait for ${step.selector}`;
|
? `wait for ${step.selector} to disappear from view`
|
||||||
|
: `wait for ${step.selector} to disappear`;
|
||||||
case 'scrollToLoad': return `scroll to load all ${step.selector}`;
|
case 'scrollToLoad': return `scroll to load all ${step.selector}`;
|
||||||
case 'solveCaptcha': return `solve the ${step.provider ?? 'page'} captcha with 2captcha`;
|
case 'solveCaptcha': return `solve the ${step.provider ?? 'page'} captcha with 2captcha`;
|
||||||
case 'repeat': {
|
case 'repeat': {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const db = new Database(path.join(process.cwd(), 'autotrader.sqlite'));
|
// Next to the checkout by default, which is what every desktop instance wants.
|
||||||
|
// `AUTOFIRMER_DB` moves it onto a mounted volume for the hosted dashboard, where
|
||||||
|
// the working directory is inside a container image and is replaced on every
|
||||||
|
// deploy — a database written there would be a fresh empty one each time.
|
||||||
|
const db = new Database(process.env.AUTOFIRMER_DB || path.join(process.cwd(), 'autotrader.sqlite'));
|
||||||
|
|
||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
@@ -627,7 +631,7 @@ export const RUNNER_TIMEOUT_MS = 7000;
|
|||||||
/** The runner version this server's step vocabulary requires. A running process
|
/** The runner version this server's step vocabulary requires. A running process
|
||||||
* doesn't reload when the source changes, so an older one silently fails on
|
* doesn't reload when the source changes, so an older one silently fails on
|
||||||
* steps it predates — the dashboard warns instead. */
|
* steps it predates — the dashboard warns instead. */
|
||||||
export const RUNNER_EXPECTED_VERSION = '0.17.0';
|
export const RUNNER_EXPECTED_VERSION = '0.18.0';
|
||||||
|
|
||||||
export interface RunnerHeartbeat {
|
export interface RunnerHeartbeat {
|
||||||
at: number;
|
at: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user