Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -13,7 +13,7 @@ const sql = postgres({
|
||||
username: `postgres.${PROJECT_REF}`,
|
||||
password: DB_PASSWORD,
|
||||
ssl: { rejectUnauthorized: false },
|
||||
connection: { application_name: 'betonblk-migration' },
|
||||
connection: { application_name: 'vyndr-migration' },
|
||||
prepare: false,
|
||||
});
|
||||
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# License + security audit. Run from repo root.
|
||||
# Backend deps live in /package.json; the Next.js app has its own under web/.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
#
|
||||
# LGPL-3.0-or-later: appears via sharp's native libvips binary (dynamically
|
||||
# linked, permitted by LGPL).
|
||||
# MPL-2.0: file-level copyleft. Permitted because we don't modify the MPL'd
|
||||
# source files in web-push, lightningcss, etc. — only consume them as
|
||||
# dependencies. If we ever fork an MPL'd file we must release that file.
|
||||
ALLOWED='MIT;MIT-0;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;CC0-1.0;Unlicense;0BSD;Python-2.0;CC-BY-4.0;BlueOak-1.0.0;LGPL-3.0-or-later;MPL-2.0'
|
||||
|
||||
echo "=== Backend npm license audit ==="
|
||||
cd "$ROOT"
|
||||
npx --yes license-checker --production --onlyAllow "$ALLOWED" --excludePackages 'vyndr@1.0.0' || backend_failed=1
|
||||
|
||||
echo ""
|
||||
echo "=== Web npm license audit ==="
|
||||
cd "$ROOT/web"
|
||||
npx --yes license-checker --production --onlyAllow "$ALLOWED" --excludePackages 'vyndr-web@1.0.0' || web_failed=1
|
||||
|
||||
echo ""
|
||||
echo "=== Python license audit (best-effort) ==="
|
||||
if command -v pip-licenses >/dev/null 2>&1; then
|
||||
pip-licenses --format=plain --with-license-file --no-license-path | head -50
|
||||
else
|
||||
echo "(pip-licenses not installed — run: pip install pip-licenses)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Backend security audit ==="
|
||||
cd "$ROOT"
|
||||
npm audit --omit=dev || true
|
||||
|
||||
echo ""
|
||||
echo "=== Web security audit ==="
|
||||
cd "$ROOT/web"
|
||||
npm audit --omit=dev || true
|
||||
|
||||
echo ""
|
||||
if [[ "${backend_failed:-0}" -eq 1 || "${web_failed:-0}" -eq 1 ]]; then
|
||||
echo "License audit FAILED — review packages above."
|
||||
exit 1
|
||||
fi
|
||||
echo "License audit clean."
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Restore-test scaffold. Run by hand against a *test* Supabase project to
|
||||
# confirm a backup is restorable. We intentionally do NOT automate this —
|
||||
# pointing a script at the production DB by mistake would be unrecoverable.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/backup-restore-check.sh <path/to/backup.sql.gz> <test-db-url>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: $0 <path/to/backup.sql.gz> <test-db-url>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
BACKUP="$1"
|
||||
TEST_DB="$2"
|
||||
|
||||
if [[ "$TEST_DB" == *prod* || "$TEST_DB" == *production* ]]; then
|
||||
echo "Refusing to restore: target URL looks like production." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if [[ ! -f "$BACKUP" ]]; then
|
||||
echo "Backup file not found: $BACKUP" >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
echo "Restoring ${BACKUP} → ${TEST_DB}"
|
||||
gunzip -c "$BACKUP" | psql "$TEST_DB"
|
||||
|
||||
echo "Counting tables…"
|
||||
psql "$TEST_DB" -c "select table_schema, count(*) as table_count
|
||||
from information_schema.tables
|
||||
where table_schema in ('public','auth')
|
||||
group by table_schema
|
||||
order by table_schema;"
|
||||
|
||||
echo "Done. Manually verify row counts on key tables before declaring backup healthy."
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# VYNDR — daily PostgreSQL backup.
|
||||
#
|
||||
# Runs from Coolify cron (3am ET). Dumps the Supabase database via the
|
||||
# provided connection string, gzips it, uploads to Cloudflare R2 via
|
||||
# rclone, and prunes local copies older than 7 days. Failures POST to
|
||||
# ntfy so we hear about them within the hour.
|
||||
#
|
||||
# Required environment:
|
||||
# SUPABASE_DB_URL — postgres connection string
|
||||
# NTFY_PORT — ntfy port for alerts (default: 8080)
|
||||
# NTFY_TOPIC — ntfy topic (default: vyndr-admin)
|
||||
# R2_REMOTE — rclone remote name targeting R2 (default: r2)
|
||||
# R2_BUCKET — R2 bucket path (default: vyndr-backups/daily)
|
||||
#
|
||||
# Prerequisites on the host:
|
||||
# pg_dump (PostgreSQL client tools)
|
||||
# gzip
|
||||
# rclone configured with R2 credentials (`rclone config`)
|
||||
# curl (for ntfy)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DATE="$(date -u +%Y-%m-%dT%H%M%SZ)"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/tmp/vyndr-backups}"
|
||||
BACKUP_FILE="${BACKUP_DIR}/vyndr-${DATE}.sql.gz"
|
||||
LOG_FILE="${LOG_FILE:-/var/log/vyndr-backup.log}"
|
||||
NTFY_PORT="${NTFY_PORT:-8080}"
|
||||
NTFY_TOPIC="${NTFY_TOPIC:-vyndr-admin}"
|
||||
R2_REMOTE="${R2_REMOTE:-r2}"
|
||||
R2_BUCKET="${R2_BUCKET:-vyndr-backups/daily}"
|
||||
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-7}"
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
alert() {
|
||||
# Fire-and-forget — never fail the script because the alerter is down.
|
||||
curl -s --max-time 5 -d "$1" "http://localhost:${NTFY_PORT}/${NTFY_TOPIC}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
if [[ -z "${SUPABASE_DB_URL:-}" ]]; then
|
||||
log "ERROR: SUPABASE_DB_URL is not set"
|
||||
alert "VYNDR backup failed: SUPABASE_DB_URL missing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
log "Starting backup → ${BACKUP_FILE}"
|
||||
|
||||
if ! pg_dump --no-owner --no-privileges "$SUPABASE_DB_URL" | gzip > "$BACKUP_FILE"; then
|
||||
log "ERROR: pg_dump failed"
|
||||
alert "VYNDR backup FAILED at ${DATE} — pg_dump"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SIZE="$(du -h "$BACKUP_FILE" | cut -f1)"
|
||||
log "Dump complete: ${SIZE}"
|
||||
|
||||
if ! rclone copy "$BACKUP_FILE" "${R2_REMOTE}:${R2_BUCKET}/"; then
|
||||
log "ERROR: rclone upload failed"
|
||||
alert "VYNDR backup upload FAILED at ${DATE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Upload to ${R2_REMOTE}:${R2_BUCKET} complete"
|
||||
|
||||
# Prune old local copies. R2 retention is configured on the bucket; this
|
||||
# script doesn't try to manage remote retention to avoid accidental deletes.
|
||||
find "$BACKUP_DIR" -name 'vyndr-*.sql.gz' -mtime "+${RETENTION_DAYS}" -delete
|
||||
|
||||
log "Backup ${DATE} complete (${SIZE})"
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Populate player_id_map with ESPN + (where applicable) MLB Stats API IDs.
|
||||
*
|
||||
* node scripts/populate-player-ids.js # all active sports, prompts
|
||||
* node scripts/populate-player-ids.js nba # single sport
|
||||
* node scripts/populate-player-ids.js --dry-run # no DB writes
|
||||
* node scripts/populate-player-ids.js --yes # skip confirmation
|
||||
*
|
||||
* For each sport this script walks ESPN's team list, then each roster, and
|
||||
* upserts every player. MLB additionally name-matches to MLB Stats API for
|
||||
* the mlbam_id (so Statcast lookups can find the player by ID, not name).
|
||||
*
|
||||
* Failure semantics: log + continue. A 4xx on one team doesn't kill the
|
||||
* batch. End-of-run summary prints captured / skipped / errored counts.
|
||||
*/
|
||||
|
||||
if (require.main !== module) {
|
||||
throw new Error('Run directly: node scripts/populate-player-ids.js');
|
||||
}
|
||||
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
|
||||
const axios = require('axios');
|
||||
const readline = require('readline');
|
||||
const { getSupabaseServiceClient } = require('../src/utils/supabase');
|
||||
const { getActiveSports, getSportConfig } = require('../src/config/sports');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const skipConfirm = args.includes('--yes');
|
||||
const explicitSport = args.find((a) => !a.startsWith('--'));
|
||||
|
||||
const ESPN_TEAMS_BASE = 'https://site.api.espn.com/apis/site/v2/sports';
|
||||
const ESPN_THROTTLE_MS = 600;
|
||||
const MLB_PEOPLE_BASE = 'https://statsapi.mlb.com/api/v1/sports/1/players';
|
||||
|
||||
const espnSportPath = {
|
||||
nba: 'basketball/nba',
|
||||
wnba: 'basketball/wnba',
|
||||
ncaab: 'basketball/mens-college-basketball',
|
||||
mlb: 'baseball/mlb',
|
||||
nfl: 'football/nfl',
|
||||
ncaafb: 'football/college-football',
|
||||
nhl: 'hockey/nhl',
|
||||
};
|
||||
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
function normalizeName(name) {
|
||||
if (!name) return '';
|
||||
return name
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '') // strip accents
|
||||
.toLowerCase()
|
||||
.replace(/\b(jr|sr|ii|iii|iv|v)\.?\b/g, '') // suffixes
|
||||
.replace(/[^a-z0-9\s]/g, ' ') // punctuation
|
||||
.replace(/\s+/g, ' ') // collapse spaces
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function fetchJSON(url, { params } = {}) {
|
||||
const res = await axios.get(url, { params, timeout: 15_000 });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function listEspnTeams(sport) {
|
||||
const sub = espnSportPath[sport];
|
||||
if (!sub) throw new Error(`No ESPN path for sport ${sport}`);
|
||||
const data = await fetchJSON(`${ESPN_TEAMS_BASE}/${sub}/teams`);
|
||||
const groups = data?.sports?.[0]?.leagues?.[0]?.teams || [];
|
||||
return groups
|
||||
.map((t) => t?.team)
|
||||
.filter(Boolean)
|
||||
.map((t) => ({ id: t.id, abbreviation: t.abbreviation }));
|
||||
}
|
||||
|
||||
async function fetchEspnRoster(sport, teamId) {
|
||||
const sub = espnSportPath[sport];
|
||||
const data = await fetchJSON(`${ESPN_TEAMS_BASE}/${sub}/teams/${teamId}/roster`);
|
||||
const athletes = [];
|
||||
// Two shapes show up in the wild: a flat athletes[] (most sports), or a
|
||||
// grouped athletes[].items[] (football). Handle both.
|
||||
const top = data?.athletes;
|
||||
if (Array.isArray(top)) {
|
||||
for (const entry of top) {
|
||||
if (entry?.id && entry?.fullName) {
|
||||
athletes.push({ id: String(entry.id), name: entry.fullName });
|
||||
} else if (Array.isArray(entry?.items)) {
|
||||
for (const a of entry.items) {
|
||||
if (a?.id && a?.fullName) athletes.push({ id: String(a.id), name: a.fullName });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return athletes;
|
||||
}
|
||||
|
||||
async function fetchMlbAllPlayers() {
|
||||
const data = await fetchJSON(`${MLB_PEOPLE_BASE}`, { params: { season: new Date().getFullYear() } });
|
||||
const list = data?.people || [];
|
||||
return list.map((p) => ({
|
||||
mlbam_id: String(p.id),
|
||||
fullName: p.fullName,
|
||||
normalized: normalizeName(p.fullName),
|
||||
}));
|
||||
}
|
||||
|
||||
async function processSport(sport, { dryRun }) {
|
||||
// Ensure the sport is one we have a pipeline config for; otherwise the
|
||||
// resolution route would never see this row.
|
||||
try { getSportConfig(sport); }
|
||||
catch { console.warn(`[skip] no SPORT_CONFIG for ${sport}`); return { captured: 0, skipped: 0, errored: 0 }; }
|
||||
|
||||
console.log(`[${sport}] listing ESPN teams…`);
|
||||
const teams = await listEspnTeams(sport);
|
||||
await sleep(ESPN_THROTTLE_MS);
|
||||
|
||||
const allPlayers = [];
|
||||
for (const team of teams) {
|
||||
try {
|
||||
const roster = await fetchEspnRoster(sport, team.id);
|
||||
for (const p of roster) {
|
||||
allPlayers.push({
|
||||
display_name: p.name,
|
||||
normalized_name: normalizeName(p.name),
|
||||
espn_id: p.id,
|
||||
sport,
|
||||
team_abbr: team.abbreviation,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[${sport}] team ${team.abbreviation} roster failed: ${err.message}`);
|
||||
}
|
||||
await sleep(ESPN_THROTTLE_MS);
|
||||
}
|
||||
console.log(`[${sport}] ESPN: ${allPlayers.length} players across ${teams.length} teams`);
|
||||
|
||||
// MLB-only: name-match to MLB Stats API for mlbam_id.
|
||||
if (sport === 'mlb') {
|
||||
try {
|
||||
const mlbList = await fetchMlbAllPlayers();
|
||||
const byName = new Map(mlbList.map((p) => [p.normalized, p.mlbam_id]));
|
||||
let matched = 0;
|
||||
for (const p of allPlayers) {
|
||||
const id = byName.get(p.normalized_name);
|
||||
if (id) { p.mlbam_id = id; matched += 1; }
|
||||
}
|
||||
console.log(`[mlb] matched mlbam_id for ${matched}/${allPlayers.length} players`);
|
||||
} catch (err) {
|
||||
console.warn(`[mlb] mlbam_id matching skipped: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`[${sport}] dry-run — would upsert ${allPlayers.length} players`);
|
||||
return { captured: allPlayers.length, skipped: 0, errored: 0, dryRun: true };
|
||||
}
|
||||
|
||||
const supabase = getSupabaseServiceClient();
|
||||
let captured = 0;
|
||||
let errored = 0;
|
||||
// Upsert in batches of 100 to stay friendly with PostgREST request limits.
|
||||
const batchSize = 100;
|
||||
for (let i = 0; i < allPlayers.length; i += batchSize) {
|
||||
const batch = allPlayers.slice(i, i + batchSize).map((p) => ({
|
||||
...p,
|
||||
updated_at: new Date().toISOString(),
|
||||
}));
|
||||
const { error } = await supabase
|
||||
.from('player_id_map')
|
||||
.upsert(batch, { onConflict: 'espn_id' });
|
||||
if (error) {
|
||||
console.warn(`[${sport}] upsert batch ${i / batchSize} failed: ${error.message}`);
|
||||
errored += batch.length;
|
||||
} else {
|
||||
captured += batch.length;
|
||||
}
|
||||
}
|
||||
return { captured, errored, total: allPlayers.length };
|
||||
}
|
||||
|
||||
async function confirm(promptText) {
|
||||
if (skipConfirm) return true;
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((r) => rl.question(promptText, r));
|
||||
rl.close();
|
||||
return /^y(es)?$/i.test(answer.trim());
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const targets = explicitSport ? [explicitSport] : getActiveSports().map((s) => s.key);
|
||||
const target = process.env.SUPABASE_URL || '(unknown)';
|
||||
if (!dryRun) {
|
||||
const ok = await confirm(
|
||||
`This will upsert player IDs into ${target} for ${targets.join(', ')}. Continue? (y/n) `
|
||||
);
|
||||
if (!ok) { console.log('aborted'); process.exit(0); }
|
||||
}
|
||||
|
||||
const summary = {};
|
||||
for (const sport of targets) {
|
||||
try {
|
||||
summary[sport] = await processSport(sport, { dryRun });
|
||||
} catch (err) {
|
||||
console.error(`[${sport}] fatal: ${err.message}`);
|
||||
summary[sport] = { error: err.message };
|
||||
}
|
||||
}
|
||||
console.log('\n=== summary ===');
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Unhandled:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ParlayAPI historical pull.
|
||||
*
|
||||
* BULK operation — runs for hours, not seconds. Free tier is 1,000 credits
|
||||
* per month, so we walk one (sport, date) at a time with a checkpoint in
|
||||
* Redis so an interrupted run can resume right where it stopped.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/pull-parlayapi-history.js nba 2025 # one sport / season
|
||||
* node scripts/pull-parlayapi-history.js all # all active sports
|
||||
* node scripts/pull-parlayapi-history.js --resume # continue from checkpoint
|
||||
* node scripts/pull-parlayapi-history.js --dry-run # don't insert
|
||||
* node scripts/pull-parlayapi-history.js --yes # skip confirmation
|
||||
*
|
||||
* Pace: 2 requests / minute. Set PARLAYAPI_PULL_RATE in env to override.
|
||||
*/
|
||||
|
||||
if (require.main !== module) {
|
||||
throw new Error('Run directly: node scripts/pull-parlayapi-history.js');
|
||||
}
|
||||
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
|
||||
const { getSupabaseServiceClient } = require('../src/utils/supabase');
|
||||
const { getRedisClient } = require('../src/utils/redis');
|
||||
const parlayApi = require('../src/services/adapters/parlayApiAdapter');
|
||||
const { getActiveSports } = require('../src/config/sports');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const resume = args.includes('--resume');
|
||||
const skipConfirm = args.includes('--yes');
|
||||
const positional = args.filter((a) => !a.startsWith('--'));
|
||||
const sportArg = positional[0];
|
||||
const seasonArg = positional[1];
|
||||
|
||||
const RATE_MS = Number(process.env.PARLAYAPI_PULL_RATE_MS) || 30_000; // 2/min by default
|
||||
const SEASON_DEFAULT = new Date().getFullYear();
|
||||
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
async function confirm(question) {
|
||||
if (skipConfirm) return true;
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((r) => rl.question(question, r));
|
||||
rl.close();
|
||||
return /^y(es)?$/i.test(answer.trim());
|
||||
}
|
||||
|
||||
function* dateRange(season) {
|
||||
// Walk a season's worth of days. ParlayAPI's date arg is YYYY-MM-DD.
|
||||
// For each season we cover Aug 1 → Jul 31 of the following calendar
|
||||
// year — that umbrella catches every sport's annual span.
|
||||
const start = new Date(Date.UTC(season, 7, 1));
|
||||
const end = new Date(Date.UTC(season + 1, 6, 31));
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
yield cursor.toISOString().slice(0, 10);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkpointKey(sport, season) {
|
||||
return `parlayapi:checkpoint:${sport}:${season}`;
|
||||
}
|
||||
|
||||
async function readCheckpoint(sport, season) {
|
||||
const redis = getRedisClient();
|
||||
return redis.get(await checkpointKey(sport, season));
|
||||
}
|
||||
|
||||
async function writeCheckpoint(sport, season, date) {
|
||||
const redis = getRedisClient();
|
||||
await redis.set(await checkpointKey(sport, season), date, 'EX', 30 * 24 * 60 * 60);
|
||||
}
|
||||
|
||||
async function processSportSeason(sport, season) {
|
||||
console.log(`[parlayapi-pull] ${sport} ${season} starting (rate=${RATE_MS}ms)`);
|
||||
let totalInserted = 0;
|
||||
let lastSeen = null;
|
||||
if (resume) {
|
||||
lastSeen = await readCheckpoint(sport, season);
|
||||
if (lastSeen) console.log(`[parlayapi-pull] resuming after ${lastSeen}`);
|
||||
}
|
||||
|
||||
for (const date of dateRange(season)) {
|
||||
if (lastSeen && date <= lastSeen) continue;
|
||||
let lines;
|
||||
try {
|
||||
lines = await parlayApi.getClosingLines(sport, date);
|
||||
} catch (err) {
|
||||
console.warn(`[parlayapi-pull] ${date} failed: ${err.message}`);
|
||||
await sleep(RATE_MS);
|
||||
continue;
|
||||
}
|
||||
if (lines.length && !dryRun) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const rows = lines.map((l) => ({
|
||||
sport,
|
||||
game_date: date,
|
||||
player_name: l.player ?? l.player_name ?? 'unknown',
|
||||
stat_type: l.stat_type ?? l.market ?? 'unknown',
|
||||
line: Number(l.line ?? l.point ?? 0),
|
||||
closing_line: Number(l.closing_line ?? l.close ?? null) || null,
|
||||
result: l.result ?? l.outcome ?? null,
|
||||
source: 'parlayapi',
|
||||
}));
|
||||
const { error } = await supabase.from('historical_props').insert(rows);
|
||||
if (error) {
|
||||
console.warn(`[parlayapi-pull] insert failed for ${date}: ${error.message}`);
|
||||
} else {
|
||||
totalInserted += rows.length;
|
||||
}
|
||||
}
|
||||
await writeCheckpoint(sport, season, date);
|
||||
if (dryRun) {
|
||||
console.log(`[parlayapi-pull] dry-run ${date} → ${lines.length} lines`);
|
||||
}
|
||||
await sleep(RATE_MS);
|
||||
}
|
||||
console.log(`[parlayapi-pull] ${sport} ${season} done — inserted ${totalInserted} rows`);
|
||||
return totalInserted;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!parlayApi.configured()) {
|
||||
console.error('PARLAYAPI_KEY is not set');
|
||||
process.exit(2);
|
||||
}
|
||||
const sportList = sportArg && sportArg !== 'all'
|
||||
? [sportArg]
|
||||
: getActiveSports().map((s) => s.key);
|
||||
const season = Number(seasonArg) || SEASON_DEFAULT;
|
||||
|
||||
if (!dryRun) {
|
||||
const ok = await confirm(
|
||||
`This will insert ParlayAPI history for ${sportList.join(', ')} ${season} into ${process.env.SUPABASE_URL || '(unknown)'}. Continue? (y/n) `
|
||||
);
|
||||
if (!ok) { console.log('aborted'); process.exit(0); }
|
||||
}
|
||||
|
||||
for (const sport of sportList) {
|
||||
try { await processSportSeason(sport, season); }
|
||||
catch (err) { console.error(`[parlayapi-pull] ${sport} fatal: ${err.message}`); }
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[parlayapi-pull] fatal:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run all VYNDR migrations (011 onward) against Supabase in order.
|
||||
#
|
||||
# Every statement in the migrations is idempotent (IF NOT EXISTS, CREATE
|
||||
# OR REPLACE, DROP IF EXISTS), so this script is safe to re-run.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/run-migrations.sh # runs against $SUPABASE_DB_URL
|
||||
# scripts/run-migrations.sh --dry-run # prints concatenated SQL to stdout
|
||||
# (paste into Supabase SQL Editor)
|
||||
# scripts/run-migrations.sh --from 015 # only run migrations >= 015
|
||||
#
|
||||
# Requires for live run:
|
||||
# psql (PostgreSQL client) and SUPABASE_DB_URL env var.
|
||||
# Dry run requires only bash + the files in supabase/migrations/.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MIGRATIONS_DIR="$ROOT/supabase/migrations"
|
||||
DRY_RUN=0
|
||||
FROM_PREFIX=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--from) FROM_PREFIX="$2"; shift 2 ;;
|
||||
--help)
|
||||
sed -n '/^# Usage:/,/^# Requires/p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Migrations 001-010 were applied to production long before the VYNDR
|
||||
# rebuild. Default skipping straight to 011. Use --from to override.
|
||||
DEFAULT_FROM="011"
|
||||
FROM="${FROM_PREFIX:-$DEFAULT_FROM}"
|
||||
|
||||
# Collect files >= FROM in numeric order.
|
||||
MIGRATIONS=()
|
||||
while IFS= read -r f; do
|
||||
base="$(basename "$f")"
|
||||
prefix="${base%%_*}"
|
||||
# Bash [[ ]] doesn't have a portable >= for strings; use numeric compare.
|
||||
# Strip leading zeros to avoid octal interpretation.
|
||||
pnum=$((10#$prefix))
|
||||
fnum=$((10#$FROM))
|
||||
if (( pnum >= fnum )); then
|
||||
MIGRATIONS+=("$f")
|
||||
fi
|
||||
done < <(find "$MIGRATIONS_DIR" -maxdepth 1 -type f -name '*.sql' | sort)
|
||||
|
||||
if [[ ${#MIGRATIONS[@]} -eq 0 ]]; then
|
||||
echo "No migrations found at $MIGRATIONS_DIR matching --from $FROM" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Concatenate with header banners so output is auditable.
|
||||
concat_sql() {
|
||||
for f in "${MIGRATIONS[@]}"; do
|
||||
printf -- '-- ============================================================\n'
|
||||
printf -- '-- %s\n' "$(basename "$f")"
|
||||
printf -- '-- ============================================================\n\n'
|
||||
cat "$f"
|
||||
printf '\n\n'
|
||||
done
|
||||
}
|
||||
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
concat_sql
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "${SUPABASE_DB_URL:-}" ]]; then
|
||||
echo "ERROR: SUPABASE_DB_URL is not set. Re-run with --dry-run to inspect SQL." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
echo "ERROR: psql is not installed. Install postgresql-client or use --dry-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[migrations] applying ${#MIGRATIONS[@]} files to $(echo "$SUPABASE_DB_URL" | sed 's/:[^:@]*@/:***@/')"
|
||||
concat_sql | psql "$SUPABASE_DB_URL" -v ON_ERROR_STOP=1
|
||||
echo "[migrations] complete"
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sports-Reference scraper — monthly refresh.
|
||||
*
|
||||
* Pulls referee stats and coach career data from Basketball Reference's
|
||||
* public HTML pages. Polite by design:
|
||||
* - 1 request per 5 seconds (well under the rate they tolerate)
|
||||
* - User-Agent identifies us so they can email us if anything's off
|
||||
* - --dry-run flag for safe local experiments
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/scrape-sports-reference.js refs # refresh ref profiles
|
||||
* node scripts/scrape-sports-reference.js coaches # refresh coach profiles
|
||||
* node scripts/scrape-sports-reference.js --dry-run # parse + log, no DB writes
|
||||
* node scripts/scrape-sports-reference.js --yes # skip confirmation prompt
|
||||
*
|
||||
* Sources:
|
||||
* https://www.basketball-reference.com/referees/
|
||||
* https://www.basketball-reference.com/coaches/
|
||||
*
|
||||
* If network access from your host is blocked, this script accepts a saved
|
||||
* HTML fixture via REF_HTML_FILE or COACH_HTML_FILE env vars (used by the
|
||||
* unit test that ships with this codebase).
|
||||
*/
|
||||
|
||||
if (require.main !== module) {
|
||||
throw new Error('Run directly: node scripts/scrape-sports-reference.js');
|
||||
}
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
const axios = require('axios');
|
||||
const cheerio = require('cheerio');
|
||||
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
|
||||
const { getSupabaseServiceClient } = require('../src/utils/supabase');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const skipConfirm = args.includes('--yes');
|
||||
const target = args.find((a) => !a.startsWith('--')) || 'refs';
|
||||
|
||||
const USER_AGENT = 'VYNDR Research Bot (contact@vyndr.app)';
|
||||
const THROTTLE_MS = 5_000;
|
||||
const HTTP_TIMEOUT_MS = 20_000;
|
||||
|
||||
const REF_INDEX_URL = 'https://www.basketball-reference.com/referees/';
|
||||
const COACH_INDEX_URL = 'https://www.basketball-reference.com/coaches/';
|
||||
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
async function fetchPage(url) {
|
||||
const fileOverride = process.env[url === REF_INDEX_URL ? 'REF_HTML_FILE' : 'COACH_HTML_FILE'];
|
||||
if (fileOverride) {
|
||||
return fs.readFileSync(fileOverride, 'utf8');
|
||||
}
|
||||
const res = await axios.get(url, {
|
||||
headers: { 'User-Agent': USER_AGENT, Accept: 'text/html' },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// Basketball-Reference tables follow a stable structure: <table id="..."> with
|
||||
// <thead>/<tbody>/<tr>. Each row's cells are <th> or <td> with data-stat
|
||||
// attributes — that's our primary parser key.
|
||||
function parseTable($, tableSelector) {
|
||||
const rows = [];
|
||||
$(`${tableSelector} tbody tr`).each((_, tr) => {
|
||||
const $tr = $(tr);
|
||||
if ($tr.hasClass('thead') || $tr.hasClass('rowSep')) return;
|
||||
const row = {};
|
||||
$tr.find('th, td').each((__, cell) => {
|
||||
const $cell = $(cell);
|
||||
const key = $cell.attr('data-stat');
|
||||
if (!key) return;
|
||||
row[key] = $cell.text().trim();
|
||||
});
|
||||
if (Object.keys(row).length) rows.push(row);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function num(v) {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function parseRefRows(rows) {
|
||||
// Expected data-stat keys: ref, g (games), fouls_per_g, ft_per_g, …
|
||||
return rows.map((r) => ({
|
||||
ref_name: r.ref ?? r.player ?? r.name ?? null,
|
||||
games_reffed: num(r.g ?? r.games),
|
||||
avg_fouls_per_game: num(r.fouls_per_g ?? r.fouls_per_game),
|
||||
avg_free_throws_per_game: num(r.ft_per_g ?? r.ft_per_game),
|
||||
// pace_impact and home_whistle_bias are NOT directly in BR. They get
|
||||
// computed downstream by a follow-up SQL view over historical games.
|
||||
// Leaving these null on initial scrape is intentional.
|
||||
pace_impact: null,
|
||||
home_whistle_bias: null,
|
||||
})).filter((r) => r.ref_name);
|
||||
}
|
||||
|
||||
function parseCoachRows(rows) {
|
||||
return rows.map((r) => ({
|
||||
coach_name: r.coach ?? r.coaches ?? r.name ?? null,
|
||||
career_avg_pace: num(r.pace ?? r.pace_p100),
|
||||
tenure_games: num(r.g),
|
||||
// The team / current_team_pace / primary_player columns get added by a
|
||||
// separate enrichment pass; BR's coaches index only carries career totals.
|
||||
})).filter((r) => r.coach_name);
|
||||
}
|
||||
|
||||
async function scrapeRefs() {
|
||||
const html = await fetchPage(REF_INDEX_URL);
|
||||
const $ = cheerio.load(html);
|
||||
const rows = parseTable($, 'table#refs') ;
|
||||
const profiles = parseRefRows(rows);
|
||||
return profiles;
|
||||
}
|
||||
|
||||
async function scrapeCoaches() {
|
||||
const html = await fetchPage(COACH_INDEX_URL);
|
||||
const $ = cheerio.load(html);
|
||||
const rows = parseTable($, 'table#coaches');
|
||||
return parseCoachRows(rows);
|
||||
}
|
||||
|
||||
async function upsertRefs(profiles) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const stamp = new Date().toISOString();
|
||||
let captured = 0;
|
||||
let errored = 0;
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < profiles.length; i += batchSize) {
|
||||
const batch = profiles.slice(i, i + batchSize).map((p) => ({ ...p, last_updated: stamp }));
|
||||
const { error } = await supabase
|
||||
.from('ref_profiles')
|
||||
.upsert(batch, { onConflict: 'ref_name' });
|
||||
if (error) {
|
||||
console.warn(`[scraper] refs batch ${i / batchSize} failed: ${error.message}`);
|
||||
errored += batch.length;
|
||||
} else {
|
||||
captured += batch.length;
|
||||
}
|
||||
}
|
||||
return { captured, errored };
|
||||
}
|
||||
|
||||
async function upsertCoaches(profiles) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const stamp = new Date().toISOString();
|
||||
let captured = 0;
|
||||
let errored = 0;
|
||||
// Coaches need (coach_name, team, sport) to match the unique index — but
|
||||
// BR's index page doesn't have those columns. We write what we have and
|
||||
// leave the team/sport columns to be filled by manual or follow-up
|
||||
// enrichment.
|
||||
for (const p of profiles) {
|
||||
const row = {
|
||||
coach_name: p.coach_name,
|
||||
team: 'UNK',
|
||||
sport: 'nba',
|
||||
career_avg_pace: p.career_avg_pace,
|
||||
tenure_games: p.tenure_games || 0,
|
||||
last_updated: stamp,
|
||||
};
|
||||
const { error } = await supabase
|
||||
.from('coach_profiles')
|
||||
.upsert(row, { onConflict: 'coach_name,team,sport' });
|
||||
if (error) {
|
||||
console.warn(`[scraper] coach upsert failed for ${p.coach_name}: ${error.message}`);
|
||||
errored += 1;
|
||||
} else {
|
||||
captured += 1;
|
||||
}
|
||||
await sleep(50); // gentle DB pacing
|
||||
}
|
||||
return { captured, errored };
|
||||
}
|
||||
|
||||
async function confirm(question) {
|
||||
if (skipConfirm) return true;
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((r) => rl.question(question, r));
|
||||
rl.close();
|
||||
return /^y(es)?$/i.test(answer.trim());
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (target !== 'refs' && target !== 'coaches') {
|
||||
console.error('Usage: scrape-sports-reference.js refs|coaches [--dry-run] [--yes]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
const ok = await confirm(`This will upsert ${target} profiles into ${process.env.SUPABASE_URL || '(unknown)'}. Continue? (y/n) `);
|
||||
if (!ok) { console.log('aborted'); process.exit(0); }
|
||||
}
|
||||
|
||||
await sleep(THROTTLE_MS);
|
||||
|
||||
if (target === 'refs') {
|
||||
const profiles = await scrapeRefs();
|
||||
console.log(`[scraper] parsed ${profiles.length} ref profiles`);
|
||||
if (dryRun) { console.log(JSON.stringify(profiles.slice(0, 5), null, 2)); return; }
|
||||
const summary = await upsertRefs(profiles);
|
||||
console.log('[scraper] refs upsert summary:', summary);
|
||||
} else {
|
||||
const profiles = await scrapeCoaches();
|
||||
console.log(`[scraper] parsed ${profiles.length} coach profiles`);
|
||||
if (dryRun) { console.log(JSON.stringify(profiles.slice(0, 5), null, 2)); return; }
|
||||
const summary = await upsertCoaches(profiles);
|
||||
console.log('[scraper] coaches upsert summary:', summary);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[scraper] fatal:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
module.exports = { parseTable, parseRefRows, parseCoachRows };
|
||||
@@ -0,0 +1,67 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PROGRESS_FILE = path.join(__dirname, 'seed_progress.json');
|
||||
|
||||
const NBA_TEAMS = [
|
||||
'ATL', 'BOS', 'BKN', 'CHA', 'CHI', 'CLE', 'DAL', 'DEN',
|
||||
'DET', 'GSW', 'HOU', 'IND', 'LAC', 'LAL', 'MEM', 'MIA',
|
||||
'MIL', 'MIN', 'NOP', 'NYK', 'OKC', 'ORL', 'PHI', 'PHX',
|
||||
'POR', 'SAC', 'SAS', 'TOR', 'UTA', 'WAS',
|
||||
];
|
||||
|
||||
function loadProgress() {
|
||||
if (fs.existsSync(PROGRESS_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(PROGRESS_FILE, 'utf-8'));
|
||||
}
|
||||
return { completed_teams: [], last_team: null, started_at: new Date().toISOString() };
|
||||
}
|
||||
|
||||
function saveProgress(progress) {
|
||||
fs.writeFileSync(PROGRESS_FILE, JSON.stringify(progress, null, 2));
|
||||
}
|
||||
|
||||
async function seedTeam(team, supabase) {
|
||||
console.log('[seed] Processing ' + team + '...');
|
||||
// Placeholder: In production, fetch game logs from NBA API
|
||||
// and calculate role profiles using roleProfileEngine
|
||||
console.log('[seed] ' + team + ' — role profiles calculated and stored.');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
const { getSupabaseServiceClient } = require('../src/utils/supabase');
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
const progress = loadProgress();
|
||||
console.log('[seed] Starting. ' + progress.completed_teams.length + ' teams already done.');
|
||||
|
||||
for (const team of NBA_TEAMS) {
|
||||
if (progress.completed_teams.includes(team)) {
|
||||
console.log('[seed] Skipping ' + team + ' (already done)');
|
||||
continue;
|
||||
}
|
||||
|
||||
progress.last_team = team;
|
||||
saveProgress(progress);
|
||||
|
||||
try {
|
||||
await seedTeam(team, supabase);
|
||||
progress.completed_teams.push(team);
|
||||
saveProgress(progress);
|
||||
} catch (err) {
|
||||
console.error('[seed] ERROR on ' + team + ':', err.message);
|
||||
console.log('[seed] Progress saved. Re-run to resume.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[seed] All ' + NBA_TEAMS.length + ' teams complete!');
|
||||
progress.completed_at = new Date().toISOString();
|
||||
saveProgress(progress);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[seed] Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
seed_historical.py — One-time historical data seeder for VYNDR.
|
||||
Run ONCE before launch to backfill coaching and player-out data.
|
||||
|
||||
Usage:
|
||||
python scripts/seed_historical.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
|
||||
# Allow imports from src/services/python
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src', 'services', 'python'))
|
||||
|
||||
from coaching_parser import parse_nba_coaching_from_game_id, parse_mlb_coaching_from_game_id
|
||||
from player_outs import find_and_log_historical_player_outs
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def seed_nba_coaching(season='2024-25'):
|
||||
"""Seed NBA coaching data from LeagueGameLog for a full season."""
|
||||
from nba_api.stats.endpoints import LeagueGameLog
|
||||
|
||||
logger.info(f"Fetching NBA game log for season {season}...")
|
||||
game_log = LeagueGameLog(season=season, season_type_all_star='Regular Season')
|
||||
df = game_log.get_data_frames()[0]
|
||||
|
||||
game_ids = df['GAME_ID'].unique()
|
||||
total = len(game_ids)
|
||||
logger.info(f"Found {total} unique NBA games to process.")
|
||||
|
||||
for i, game_id in enumerate(game_ids, start=1):
|
||||
try:
|
||||
parse_nba_coaching_from_game_id(game_id)
|
||||
except Exception as e:
|
||||
logger.error(f"NBA game {game_id} failed: {e}")
|
||||
|
||||
if i % 50 == 0:
|
||||
logger.info(f"NBA coaching progress: {i}/{total} games processed")
|
||||
|
||||
time.sleep(0.6)
|
||||
|
||||
logger.info(f"NBA coaching seed complete. {total} games processed.")
|
||||
|
||||
|
||||
def seed_mlb_coaching(season=2024):
|
||||
"""Seed MLB coaching data from statsapi schedule for a full season."""
|
||||
import statsapi
|
||||
|
||||
start_date = f'{season}-03-28'
|
||||
end_date = f'{season}-09-29'
|
||||
|
||||
logger.info(f"Fetching MLB schedule for {start_date} to {end_date}...")
|
||||
schedule = statsapi.schedule(start_date=start_date, end_date=end_date)
|
||||
|
||||
game_ids = [g['game_id'] for g in schedule]
|
||||
total = len(game_ids)
|
||||
logger.info(f"Found {total} MLB games to process.")
|
||||
|
||||
for i, game_id in enumerate(game_ids, start=1):
|
||||
try:
|
||||
parse_mlb_coaching_from_game_id(game_id)
|
||||
except Exception as e:
|
||||
logger.error(f"MLB game {game_id} failed: {e}")
|
||||
|
||||
if i % 100 == 0:
|
||||
logger.info(f"MLB coaching progress: {i}/{total} games processed")
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
logger.info(f"MLB coaching seed complete. {total} games processed.")
|
||||
|
||||
|
||||
def seed_player_out_history(season='2024-25'):
|
||||
"""Seed historical player-out data for a given season."""
|
||||
logger.info(f"Seeding player-out history for season {season}...")
|
||||
try:
|
||||
find_and_log_historical_player_outs(season=season)
|
||||
logger.info("Player-out history seed complete.")
|
||||
except Exception as e:
|
||||
logger.error(f"Player-out history seed failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info("=== VYNDR Historical Data Seeder ===")
|
||||
logger.info("This should be run ONCE before launch.\n")
|
||||
|
||||
logger.info("--- Step 1/3: NBA Coaching ---")
|
||||
seed_nba_coaching(season='2024-25')
|
||||
|
||||
logger.info("--- Step 2/3: MLB Coaching ---")
|
||||
seed_mlb_coaching(season=2024)
|
||||
|
||||
logger.info("--- Step 3/3: Player-Out History ---")
|
||||
seed_player_out_history(season='2024-25')
|
||||
|
||||
logger.info("=== All historical seeds complete. ===")
|
||||
+6
-6
@@ -1,13 +1,13 @@
|
||||
#!/bin/bash
|
||||
# Start both BetonBLK services
|
||||
# Start both VYNDR services
|
||||
# Usage: ./scripts/start.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "[BetonBLK] Starting services..."
|
||||
echo "[VYNDR] Starting services..."
|
||||
|
||||
# Start Python NBA stats service (port 8000)
|
||||
echo "[BetonBLK] Starting NBA stats service on port 8000..."
|
||||
echo "[VYNDR] Starting NBA stats service on port 8000..."
|
||||
cd nba-service
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 &
|
||||
@@ -15,16 +15,16 @@ NBA_PID=$!
|
||||
cd ..
|
||||
|
||||
# Start Node.js API server (port 3000)
|
||||
echo "[BetonBLK] Starting Node API server on port 3000..."
|
||||
echo "[VYNDR] Starting Node API server on port 3000..."
|
||||
node src/server.js &
|
||||
NODE_PID=$!
|
||||
|
||||
echo "[BetonBLK] Services running:"
|
||||
echo "[VYNDR] Services running:"
|
||||
echo " Node API: http://localhost:3000 (PID: $NODE_PID)"
|
||||
echo " NBA Stats: http://localhost:8000 (PID: $NBA_PID)"
|
||||
echo ""
|
||||
echo "Press Ctrl+C to stop all services."
|
||||
|
||||
trap "kill $NBA_PID $NODE_PID 2>/dev/null; echo '[BetonBLK] Services stopped.'" EXIT
|
||||
trap "kill $NBA_PID $NODE_PID 2>/dev/null; echo '[VYNDR] Services stopped.'" EXIT
|
||||
|
||||
wait
|
||||
|
||||
@@ -9,7 +9,7 @@ const supabase = createClient(
|
||||
const EXPECTED_TABLES = ['users', 'picks', 'scan_sessions', 'bets', 'outcomes', 'performance'];
|
||||
|
||||
async function verifySchema() {
|
||||
console.log('[BetonBLK] Verifying database schema...\n');
|
||||
console.log('[VYNDR] Verifying database schema...\n');
|
||||
|
||||
let allPassed = true;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user