Spaces:
Sleeping
Sleeping
Commit ·
b457dd3
1
Parent(s): 5ac82ff
Add cache backup support
Browse files- .gitignore +1 -0
- Dockerfile +1 -0
- README.md +10 -1
- package-lock.json +66 -5
- package.json +1 -0
- server/index.js +134 -5
- spec.md +8 -2
.gitignore
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
node_modules
|
| 2 |
dist
|
| 3 |
.cache
|
|
|
|
| 4 |
.DS_Store
|
| 5 |
.env
|
| 6 |
.env.*
|
|
|
|
| 1 |
node_modules
|
| 2 |
dist
|
| 3 |
.cache
|
| 4 |
+
.idea
|
| 5 |
.DS_Store
|
| 6 |
.env
|
| 7 |
.env.*
|
Dockerfile
CHANGED
|
@@ -20,6 +20,7 @@ RUN npm install --omit=dev
|
|
| 20 |
|
| 21 |
COPY --from=build /app/dist ./dist
|
| 22 |
COPY server ./server
|
|
|
|
| 23 |
|
| 24 |
EXPOSE 7860
|
| 25 |
|
|
|
|
| 20 |
|
| 21 |
COPY --from=build /app/dist ./dist
|
| 22 |
COPY server ./server
|
| 23 |
+
COPY dupr_players.csv ./dupr_players.csv
|
| 24 |
|
| 25 |
EXPOSE 7860
|
| 26 |
|
README.md
CHANGED
|
@@ -24,13 +24,20 @@ This Hugging Face Docker Space displays a May 2026 DUPR event roster from `dupr_
|
|
| 24 |
Add this Hugging Face Space Secret:
|
| 25 |
|
| 26 |
- `DUPR_BEARER_TOKEN`
|
|
|
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
## Data Files
|
| 31 |
|
| 32 |
- `dupr_players.csv` is the roster source.
|
| 33 |
- `.cache/player-cache.json` is generated at runtime and stores DUPR lookups, ratings, match summaries, and unresolved lookup errors.
|
|
|
|
| 34 |
|
| 35 |
The CSV expects these columns:
|
| 36 |
|
|
@@ -46,6 +53,7 @@ Create a local `.env` file:
|
|
| 46 |
|
| 47 |
```bash
|
| 48 |
DUPR_BEARER_TOKEN=your_token
|
|
|
|
| 49 |
```
|
| 50 |
|
| 51 |
Then run:
|
|
@@ -71,5 +79,6 @@ Then open `http://127.0.0.1:7860`.
|
|
| 71 |
- Runtime port is `7860`.
|
| 72 |
- Create the Space with the Docker SDK.
|
| 73 |
- Keep `DUPR_BEARER_TOKEN` configured as a Space Secret.
|
|
|
|
| 74 |
- The DUPR token can expire and may need to be refreshed.
|
| 75 |
- Do not commit tokens or private DUPR session data.
|
|
|
|
| 24 |
Add this Hugging Face Space Secret:
|
| 25 |
|
| 26 |
- `DUPR_BEARER_TOKEN`
|
| 27 |
+
- `HF_TOKEN`
|
| 28 |
|
| 29 |
+
`DUPR_BEARER_TOKEN` should be a valid DUPR bearer token copied from an authenticated DUPR session. `HF_TOKEN` should have write access to the backup dataset. Keep the Space private unless you are comfortable with other users making requests through that session.
|
| 30 |
+
|
| 31 |
+
Optional cache backup settings:
|
| 32 |
+
|
| 33 |
+
- `HF_DATASET_REPO`, defaults to `derek-thomas/dupr_explorer_may_2026`
|
| 34 |
+
- `HF_CACHE_PATH`, defaults to `player-cache.json`
|
| 35 |
|
| 36 |
## Data Files
|
| 37 |
|
| 38 |
- `dupr_players.csv` is the roster source.
|
| 39 |
- `.cache/player-cache.json` is generated at runtime and stores DUPR lookups, ratings, match summaries, and unresolved lookup errors.
|
| 40 |
+
- When `HF_TOKEN` is configured, the server restores `.cache/player-cache.json` from the Hugging Face dataset if the local file is missing, then backs up each cache write to `derek-thomas/dupr_explorer_may_2026`.
|
| 41 |
|
| 42 |
The CSV expects these columns:
|
| 43 |
|
|
|
|
| 53 |
|
| 54 |
```bash
|
| 55 |
DUPR_BEARER_TOKEN=your_token
|
| 56 |
+
HF_TOKEN=your_hf_token
|
| 57 |
```
|
| 58 |
|
| 59 |
Then run:
|
|
|
|
| 79 |
- Runtime port is `7860`.
|
| 80 |
- Create the Space with the Docker SDK.
|
| 81 |
- Keep `DUPR_BEARER_TOKEN` configured as a Space Secret.
|
| 82 |
+
- Keep `HF_TOKEN` configured as a Space Secret so runtime cache updates survive Space restarts and DUPR token interruptions.
|
| 83 |
- The DUPR token can expire and may need to be refreshed.
|
| 84 |
- Do not commit tokens or private DUPR session data.
|
package-lock.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
| 8 |
"name": "dupr-url-resolver",
|
| 9 |
"version": "1.0.0",
|
| 10 |
"dependencies": {
|
|
|
|
| 11 |
"dotenv": "^17.4.2",
|
| 12 |
"express": "^4.19.2"
|
| 13 |
},
|
|
@@ -748,6 +749,47 @@
|
|
| 748 |
"node": ">=18"
|
| 749 |
}
|
| 750 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
"node_modules/@jridgewell/gen-mapping": {
|
| 752 |
"version": "0.3.13",
|
| 753 |
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
|
@@ -1284,7 +1326,7 @@
|
|
| 1284 |
"version": "5.0.1",
|
| 1285 |
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
| 1286 |
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
| 1287 |
-
"
|
| 1288 |
"license": "MIT",
|
| 1289 |
"engines": {
|
| 1290 |
"node": ">=8"
|
|
@@ -1487,6 +1529,19 @@
|
|
| 1487 |
"node": ">=8"
|
| 1488 |
}
|
| 1489 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1490 |
"node_modules/cliui": {
|
| 1491 |
"version": "8.0.1",
|
| 1492 |
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
|
@@ -1670,7 +1725,7 @@
|
|
| 1670 |
"version": "8.0.0",
|
| 1671 |
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
| 1672 |
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
| 1673 |
-
"
|
| 1674 |
"license": "MIT"
|
| 1675 |
},
|
| 1676 |
"node_modules/encodeurl": {
|
|
@@ -1933,6 +1988,12 @@
|
|
| 1933 |
"url": "https://github.com/sponsors/ljharb"
|
| 1934 |
}
|
| 1935 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1936 |
"node_modules/gensync": {
|
| 1937 |
"version": "1.0.0-beta.2",
|
| 1938 |
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
|
@@ -2087,7 +2148,7 @@
|
|
| 2087 |
"version": "3.0.0",
|
| 2088 |
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
| 2089 |
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
| 2090 |
-
"
|
| 2091 |
"license": "MIT",
|
| 2092 |
"engines": {
|
| 2093 |
"node": ">=8"
|
|
@@ -2708,7 +2769,7 @@
|
|
| 2708 |
"version": "4.2.3",
|
| 2709 |
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
| 2710 |
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
| 2711 |
-
"
|
| 2712 |
"license": "MIT",
|
| 2713 |
"dependencies": {
|
| 2714 |
"emoji-regex": "^8.0.0",
|
|
@@ -2723,7 +2784,7 @@
|
|
| 2723 |
"version": "6.0.1",
|
| 2724 |
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
| 2725 |
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
| 2726 |
-
"
|
| 2727 |
"license": "MIT",
|
| 2728 |
"dependencies": {
|
| 2729 |
"ansi-regex": "^5.0.1"
|
|
|
|
| 8 |
"name": "dupr-url-resolver",
|
| 9 |
"version": "1.0.0",
|
| 10 |
"dependencies": {
|
| 11 |
+
"@huggingface/hub": "^2.13.0",
|
| 12 |
"dotenv": "^17.4.2",
|
| 13 |
"express": "^4.19.2"
|
| 14 |
},
|
|
|
|
| 749 |
"node": ">=18"
|
| 750 |
}
|
| 751 |
},
|
| 752 |
+
"node_modules/@huggingface/blake3-jit": {
|
| 753 |
+
"version": "0.0.2",
|
| 754 |
+
"resolved": "https://registry.npmjs.org/@huggingface/blake3-jit/-/blake3-jit-0.0.2.tgz",
|
| 755 |
+
"integrity": "sha512-Bq7B5qabyjrJfhBsl85Jd2QBtf+HzRD7h7A9GfN2lzrrsABhOa5evVPgzoCTxR7Ub0QFj7YDK1YkYRWBU25+2w==",
|
| 756 |
+
"license": "MIT"
|
| 757 |
+
},
|
| 758 |
+
"node_modules/@huggingface/hub": {
|
| 759 |
+
"version": "2.13.0",
|
| 760 |
+
"resolved": "https://registry.npmjs.org/@huggingface/hub/-/hub-2.13.0.tgz",
|
| 761 |
+
"integrity": "sha512-IAoqdpTV9HeMyooxKVvVGWirOJ+S2IAKnU2FSQSMz62ehPTKzxADAATy1fwAlYYQdHCV119GHFf4p9+ECQ+I5g==",
|
| 762 |
+
"license": "MIT",
|
| 763 |
+
"dependencies": {
|
| 764 |
+
"@huggingface/tasks": "^0.21.1",
|
| 765 |
+
"@huggingface/xetchunk-wasm": "^0.0.6"
|
| 766 |
+
},
|
| 767 |
+
"bin": {
|
| 768 |
+
"hfjs": "dist/cli.js"
|
| 769 |
+
},
|
| 770 |
+
"engines": {
|
| 771 |
+
"node": ">=18"
|
| 772 |
+
},
|
| 773 |
+
"optionalDependencies": {
|
| 774 |
+
"cli-progress": "^3.12.0"
|
| 775 |
+
}
|
| 776 |
+
},
|
| 777 |
+
"node_modules/@huggingface/tasks": {
|
| 778 |
+
"version": "0.21.1",
|
| 779 |
+
"resolved": "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.21.1.tgz",
|
| 780 |
+
"integrity": "sha512-EGy9VE8h9d39JgyKY5+Nwl0mcGQOK3el8rlR9Y09KVCZOHsLHAsOP1e7D9M7P781dSObZW9lokdfT+Sm26Iv3g==",
|
| 781 |
+
"license": "MIT"
|
| 782 |
+
},
|
| 783 |
+
"node_modules/@huggingface/xetchunk-wasm": {
|
| 784 |
+
"version": "0.0.6",
|
| 785 |
+
"resolved": "https://registry.npmjs.org/@huggingface/xetchunk-wasm/-/xetchunk-wasm-0.0.6.tgz",
|
| 786 |
+
"integrity": "sha512-LoYPl7jvUvOnkysrhMjAeBzK5mVe2GFu8O3IsZb1w7l7v+NqjDsyRduwct3yraPAGmzTxxdb/2aIORL98Y949g==",
|
| 787 |
+
"license": "MIT",
|
| 788 |
+
"dependencies": {
|
| 789 |
+
"@huggingface/blake3-jit": "0.0.2",
|
| 790 |
+
"gearhash-jit": "1.0.2"
|
| 791 |
+
}
|
| 792 |
+
},
|
| 793 |
"node_modules/@jridgewell/gen-mapping": {
|
| 794 |
"version": "0.3.13",
|
| 795 |
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
|
|
|
| 1326 |
"version": "5.0.1",
|
| 1327 |
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
| 1328 |
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
| 1329 |
+
"devOptional": true,
|
| 1330 |
"license": "MIT",
|
| 1331 |
"engines": {
|
| 1332 |
"node": ">=8"
|
|
|
|
| 1529 |
"node": ">=8"
|
| 1530 |
}
|
| 1531 |
},
|
| 1532 |
+
"node_modules/cli-progress": {
|
| 1533 |
+
"version": "3.12.0",
|
| 1534 |
+
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
|
| 1535 |
+
"integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==",
|
| 1536 |
+
"license": "MIT",
|
| 1537 |
+
"optional": true,
|
| 1538 |
+
"dependencies": {
|
| 1539 |
+
"string-width": "^4.2.3"
|
| 1540 |
+
},
|
| 1541 |
+
"engines": {
|
| 1542 |
+
"node": ">=4"
|
| 1543 |
+
}
|
| 1544 |
+
},
|
| 1545 |
"node_modules/cliui": {
|
| 1546 |
"version": "8.0.1",
|
| 1547 |
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
|
|
|
| 1725 |
"version": "8.0.0",
|
| 1726 |
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
| 1727 |
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
| 1728 |
+
"devOptional": true,
|
| 1729 |
"license": "MIT"
|
| 1730 |
},
|
| 1731 |
"node_modules/encodeurl": {
|
|
|
|
| 1988 |
"url": "https://github.com/sponsors/ljharb"
|
| 1989 |
}
|
| 1990 |
},
|
| 1991 |
+
"node_modules/gearhash-jit": {
|
| 1992 |
+
"version": "1.0.2",
|
| 1993 |
+
"resolved": "https://registry.npmjs.org/gearhash-jit/-/gearhash-jit-1.0.2.tgz",
|
| 1994 |
+
"integrity": "sha512-UhzJL4KXSdqAKepy/tZwmi2Rcy0YMmtiC4DQS4SURCuIWdh8ECZtnXK2ePRMLigfB61hRKdLK/Vgg2bSw73izQ==",
|
| 1995 |
+
"license": "MIT"
|
| 1996 |
+
},
|
| 1997 |
"node_modules/gensync": {
|
| 1998 |
"version": "1.0.0-beta.2",
|
| 1999 |
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
|
|
|
| 2148 |
"version": "3.0.0",
|
| 2149 |
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
| 2150 |
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
| 2151 |
+
"devOptional": true,
|
| 2152 |
"license": "MIT",
|
| 2153 |
"engines": {
|
| 2154 |
"node": ">=8"
|
|
|
|
| 2769 |
"version": "4.2.3",
|
| 2770 |
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
| 2771 |
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
| 2772 |
+
"devOptional": true,
|
| 2773 |
"license": "MIT",
|
| 2774 |
"dependencies": {
|
| 2775 |
"emoji-regex": "^8.0.0",
|
|
|
|
| 2784 |
"version": "6.0.1",
|
| 2785 |
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
| 2786 |
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
| 2787 |
+
"devOptional": true,
|
| 2788 |
"license": "MIT",
|
| 2789 |
"dependencies": {
|
| 2790 |
"ansi-regex": "^5.0.1"
|
package.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
| 9 |
"start": "NODE_ENV=production node server/index.js"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
|
|
|
| 12 |
"dotenv": "^17.4.2",
|
| 13 |
"express": "^4.19.2"
|
| 14 |
},
|
|
|
|
| 9 |
"start": "NODE_ENV=production node server/index.js"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
+
"@huggingface/hub": "^2.13.0",
|
| 13 |
"dotenv": "^17.4.2",
|
| 14 |
"express": "^4.19.2"
|
| 15 |
},
|
server/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import "dotenv/config";
|
|
| 3 |
import fs from "node:fs/promises";
|
| 4 |
import path from "node:path";
|
| 5 |
import { fileURLToPath } from "node:url";
|
|
|
|
| 6 |
|
| 7 |
const DUPR_SEARCH_URL = "https://api.dupr.gg/player/v1.0/search";
|
| 8 |
const DUPR_PLAYER_URL = "https://api.dupr.gg/player/v1.0";
|
|
@@ -11,6 +12,9 @@ const DEFAULT_LAT = 24.453884;
|
|
| 11 |
const DEFAULT_LNG = 54.3773438;
|
| 12 |
const EXPLORER_URL_PATTERN =
|
| 13 |
process.env.DUPR_EXPLORER_URL_PATTERN || "https://duprexplorer.com/player/{numericPlayerId}";
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
const __filename = fileURLToPath(import.meta.url);
|
| 16 |
const __dirname = path.dirname(__filename);
|
|
@@ -19,6 +23,13 @@ const distPath = path.resolve(rootPath, "dist");
|
|
| 19 |
const rosterPath = path.resolve(rootPath, "dupr_players.csv");
|
| 20 |
const cacheDir = path.resolve(rootPath, ".cache");
|
| 21 |
const cachePath = path.resolve(cacheDir, "player-cache.json");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
const app = express();
|
| 24 |
|
|
@@ -265,7 +276,78 @@ async function fetchMatchInfo(numericPlayerId) {
|
|
| 265 |
};
|
| 266 |
}
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
async function readCache() {
|
|
|
|
|
|
|
| 269 |
try {
|
| 270 |
const text = await fs.readFile(cachePath, "utf8");
|
| 271 |
const parsed = JSON.parse(text);
|
|
@@ -284,7 +366,33 @@ async function readCache() {
|
|
| 284 |
|
| 285 |
async function writeCache(cache) {
|
| 286 |
await fs.mkdir(cacheDir, { recursive: true });
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
}
|
| 289 |
|
| 290 |
function walkJson(value) {
|
|
@@ -545,7 +653,12 @@ async function readRoster() {
|
|
| 545 |
},
|
| 546 |
cache: {
|
| 547 |
cachedPlayers: attemptedPlayers.length,
|
| 548 |
-
explorerUrlPattern: EXPLORER_URL_PATTERN
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
}
|
| 550 |
};
|
| 551 |
}
|
|
@@ -590,10 +703,20 @@ async function refreshPlayer(duprId) {
|
|
| 590 |
await writeCache(cache);
|
| 591 |
return entry;
|
| 592 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
const entry = {
|
| 594 |
status: "error",
|
| 595 |
fetchedAt: new Date().toISOString(),
|
| 596 |
-
errorCode
|
| 597 |
message: error?.body?.message || error?.message || "Could not refresh this player.",
|
| 598 |
matchedPlayer: error?.body?.matchedPlayer ?? null,
|
| 599 |
raw: error?.body?.raw ?? null
|
|
@@ -611,7 +734,8 @@ app.get("/api/health", (_req, res) => {
|
|
| 611 |
app.get("/api/roster", async (_req, res) => {
|
| 612 |
try {
|
| 613 |
res.json(await readRoster());
|
| 614 |
-
} catch {
|
|
|
|
| 615 |
res.status(500).json({
|
| 616 |
ok: false,
|
| 617 |
errorCode: "roster_read_failed",
|
|
@@ -634,7 +758,12 @@ app.get("/api/cache", async (_req, res) => {
|
|
| 634 |
message: entry.message ?? null
|
| 635 |
}
|
| 636 |
])
|
| 637 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
});
|
| 639 |
} catch {
|
| 640 |
res.status(500).json({
|
|
|
|
| 3 |
import fs from "node:fs/promises";
|
| 4 |
import path from "node:path";
|
| 5 |
import { fileURLToPath } from "node:url";
|
| 6 |
+
import { downloadFile, uploadFile } from "@huggingface/hub";
|
| 7 |
|
| 8 |
const DUPR_SEARCH_URL = "https://api.dupr.gg/player/v1.0/search";
|
| 9 |
const DUPR_PLAYER_URL = "https://api.dupr.gg/player/v1.0";
|
|
|
|
| 12 |
const DEFAULT_LNG = 54.3773438;
|
| 13 |
const EXPLORER_URL_PATTERN =
|
| 14 |
process.env.DUPR_EXPLORER_URL_PATTERN || "https://duprexplorer.com/player/{numericPlayerId}";
|
| 15 |
+
const HF_DATASET_REPO = process.env.HF_DATASET_REPO || "derek-thomas/dupr_explorer_may_2026";
|
| 16 |
+
const HF_CACHE_PATH = process.env.HF_CACHE_PATH || "player-cache.json";
|
| 17 |
+
const HF_TOKEN = process.env.HF_TOKEN || process.env.HUGGINGFACE_HUB_TOKEN;
|
| 18 |
|
| 19 |
const __filename = fileURLToPath(import.meta.url);
|
| 20 |
const __dirname = path.dirname(__filename);
|
|
|
|
| 23 |
const rosterPath = path.resolve(rootPath, "dupr_players.csv");
|
| 24 |
const cacheDir = path.resolve(rootPath, ".cache");
|
| 25 |
const cachePath = path.resolve(cacheDir, "player-cache.json");
|
| 26 |
+
let hydrateCachePromise = null;
|
| 27 |
+
let lastCacheBackup = {
|
| 28 |
+
status: HF_TOKEN ? "pending" : "disabled",
|
| 29 |
+
message: HF_TOKEN ? "Waiting for cache changes." : "Set HF_TOKEN to enable Hugging Face dataset backup.",
|
| 30 |
+
fetchedAt: null,
|
| 31 |
+
pushedAt: null
|
| 32 |
+
};
|
| 33 |
|
| 34 |
const app = express();
|
| 35 |
|
|
|
|
| 276 |
};
|
| 277 |
}
|
| 278 |
|
| 279 |
+
function getHfRepo() {
|
| 280 |
+
return {
|
| 281 |
+
type: "dataset",
|
| 282 |
+
name: HF_DATASET_REPO
|
| 283 |
+
};
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
function updateCacheBackupStatus(status, message, extra = {}) {
|
| 287 |
+
lastCacheBackup = {
|
| 288 |
+
...lastCacheBackup,
|
| 289 |
+
status,
|
| 290 |
+
message,
|
| 291 |
+
...extra
|
| 292 |
+
};
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
async function hydrateCacheFromDataset() {
|
| 296 |
+
if (!HF_TOKEN) {
|
| 297 |
+
return;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
try {
|
| 301 |
+
await fs.access(cachePath);
|
| 302 |
+
updateCacheBackupStatus("local", "Using local cache; dataset backup is available for future writes.");
|
| 303 |
+
return;
|
| 304 |
+
} catch (error) {
|
| 305 |
+
if (error.code !== "ENOENT") {
|
| 306 |
+
throw error;
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
try {
|
| 311 |
+
const remoteFile = await downloadFile({
|
| 312 |
+
repo: getHfRepo(),
|
| 313 |
+
path: HF_CACHE_PATH,
|
| 314 |
+
accessToken: HF_TOKEN
|
| 315 |
+
});
|
| 316 |
+
|
| 317 |
+
if (!remoteFile) {
|
| 318 |
+
updateCacheBackupStatus("empty", `No cache backup found in ${HF_DATASET_REPO}/${HF_CACHE_PATH}.`);
|
| 319 |
+
return;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
const text = await remoteFile.text();
|
| 323 |
+
const parsed = JSON.parse(text);
|
| 324 |
+
|
| 325 |
+
if (!parsed.players || typeof parsed.players !== "object") {
|
| 326 |
+
throw new Error("Remote cache backup does not contain a players object.");
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
await fs.mkdir(cacheDir, { recursive: true });
|
| 330 |
+
await fs.writeFile(cachePath, text.endsWith("\n") ? text : `${text}\n`);
|
| 331 |
+
updateCacheBackupStatus("hydrated", `Hydrated cache from ${HF_DATASET_REPO}/${HF_CACHE_PATH}.`, {
|
| 332 |
+
fetchedAt: new Date().toISOString()
|
| 333 |
+
});
|
| 334 |
+
} catch (error) {
|
| 335 |
+
console.error("Could not hydrate cache from Hugging Face dataset.", error);
|
| 336 |
+
updateCacheBackupStatus(
|
| 337 |
+
"hydrate_failed",
|
| 338 |
+
"Could not hydrate cache from the Hugging Face dataset; using local cache only."
|
| 339 |
+
);
|
| 340 |
+
}
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
async function ensureCacheHydrated() {
|
| 344 |
+
hydrateCachePromise ??= hydrateCacheFromDataset();
|
| 345 |
+
await hydrateCachePromise;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
async function readCache() {
|
| 349 |
+
await ensureCacheHydrated();
|
| 350 |
+
|
| 351 |
try {
|
| 352 |
const text = await fs.readFile(cachePath, "utf8");
|
| 353 |
const parsed = JSON.parse(text);
|
|
|
|
| 366 |
|
| 367 |
async function writeCache(cache) {
|
| 368 |
await fs.mkdir(cacheDir, { recursive: true });
|
| 369 |
+
const serialized = `${JSON.stringify(cache, null, 2)}\n`;
|
| 370 |
+
await fs.writeFile(cachePath, serialized);
|
| 371 |
+
|
| 372 |
+
if (!HF_TOKEN) {
|
| 373 |
+
return;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
try {
|
| 377 |
+
await uploadFile({
|
| 378 |
+
repo: getHfRepo(),
|
| 379 |
+
file: {
|
| 380 |
+
path: HF_CACHE_PATH,
|
| 381 |
+
content: new Blob([serialized], { type: "application/json" })
|
| 382 |
+
},
|
| 383 |
+
accessToken: HF_TOKEN,
|
| 384 |
+
commitTitle: "Update DUPR player cache backup"
|
| 385 |
+
});
|
| 386 |
+
updateCacheBackupStatus("pushed", `Backed up cache to ${HF_DATASET_REPO}/${HF_CACHE_PATH}.`, {
|
| 387 |
+
pushedAt: new Date().toISOString()
|
| 388 |
+
});
|
| 389 |
+
} catch (error) {
|
| 390 |
+
console.error("Could not back up cache to Hugging Face dataset.", error);
|
| 391 |
+
updateCacheBackupStatus(
|
| 392 |
+
"push_failed",
|
| 393 |
+
"Could not back up cache to the Hugging Face dataset; local cache was still written."
|
| 394 |
+
);
|
| 395 |
+
}
|
| 396 |
}
|
| 397 |
|
| 398 |
function walkJson(value) {
|
|
|
|
| 653 |
},
|
| 654 |
cache: {
|
| 655 |
cachedPlayers: attemptedPlayers.length,
|
| 656 |
+
explorerUrlPattern: EXPLORER_URL_PATTERN,
|
| 657 |
+
backup: {
|
| 658 |
+
repo: HF_DATASET_REPO,
|
| 659 |
+
path: HF_CACHE_PATH,
|
| 660 |
+
...lastCacheBackup
|
| 661 |
+
}
|
| 662 |
}
|
| 663 |
};
|
| 664 |
}
|
|
|
|
| 703 |
await writeCache(cache);
|
| 704 |
return entry;
|
| 705 |
} catch (error) {
|
| 706 |
+
const existingEntry = cache.players[normalizedDuprId];
|
| 707 |
+
const errorCode = error?.body?.errorCode || error?.errorCode || "refresh_failed";
|
| 708 |
+
|
| 709 |
+
if (
|
| 710 |
+
existingEntry?.status === "success" &&
|
| 711 |
+
["invalid_token", "rate_limited", "network_error", "dupr_api_error"].includes(errorCode)
|
| 712 |
+
) {
|
| 713 |
+
return existingEntry;
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
const entry = {
|
| 717 |
status: "error",
|
| 718 |
fetchedAt: new Date().toISOString(),
|
| 719 |
+
errorCode,
|
| 720 |
message: error?.body?.message || error?.message || "Could not refresh this player.",
|
| 721 |
matchedPlayer: error?.body?.matchedPlayer ?? null,
|
| 722 |
raw: error?.body?.raw ?? null
|
|
|
|
| 734 |
app.get("/api/roster", async (_req, res) => {
|
| 735 |
try {
|
| 736 |
res.json(await readRoster());
|
| 737 |
+
} catch (error) {
|
| 738 |
+
console.error("Could not read roster CSV.", error);
|
| 739 |
res.status(500).json({
|
| 740 |
ok: false,
|
| 741 |
errorCode: "roster_read_failed",
|
|
|
|
| 758 |
message: entry.message ?? null
|
| 759 |
}
|
| 760 |
])
|
| 761 |
+
),
|
| 762 |
+
backup: {
|
| 763 |
+
repo: HF_DATASET_REPO,
|
| 764 |
+
path: HF_CACHE_PATH,
|
| 765 |
+
...lastCacheBackup
|
| 766 |
+
}
|
| 767 |
});
|
| 768 |
} catch {
|
| 769 |
res.status(500).json({
|
spec.md
CHANGED
|
@@ -80,7 +80,7 @@ Required detail behavior:
|
|
| 80 |
|
| 81 |
### `GET /api/roster`
|
| 82 |
|
| 83 |
-
Reads `dupr_players.csv`, joins cached DUPR enrichment from `.cache/player-cache.json`, and returns teams plus aggregate stats.
|
| 84 |
|
| 85 |
The response includes:
|
| 86 |
|
|
@@ -100,7 +100,12 @@ The response includes:
|
|
| 100 |
},
|
| 101 |
"cache": {
|
| 102 |
"cachedPlayers": 0,
|
| 103 |
-
"explorerUrlPattern": "https://duprexplorer.com/player/{numericPlayerId}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
}
|
| 106 |
```
|
|
@@ -108,6 +113,7 @@ The response includes:
|
|
| 108 |
### `POST /api/players/refresh`
|
| 109 |
|
| 110 |
Refreshes missing, failed, or incomplete player cache entries. By default it skips successful cached players that already have match information.
|
|
|
|
| 111 |
|
| 112 |
Request body:
|
| 113 |
|
|
|
|
| 80 |
|
| 81 |
### `GET /api/roster`
|
| 82 |
|
| 83 |
+
Reads `dupr_players.csv`, joins cached DUPR enrichment from `.cache/player-cache.json`, and returns teams plus aggregate stats. If the local cache is missing and `HF_TOKEN` is configured, the server first attempts to hydrate the cache from the Hugging Face dataset backup.
|
| 84 |
|
| 85 |
The response includes:
|
| 86 |
|
|
|
|
| 100 |
},
|
| 101 |
"cache": {
|
| 102 |
"cachedPlayers": 0,
|
| 103 |
+
"explorerUrlPattern": "https://duprexplorer.com/player/{numericPlayerId}",
|
| 104 |
+
"backup": {
|
| 105 |
+
"repo": "derek-thomas/dupr_explorer_may_2026",
|
| 106 |
+
"path": "player-cache.json",
|
| 107 |
+
"status": "pushed"
|
| 108 |
+
}
|
| 109 |
}
|
| 110 |
}
|
| 111 |
```
|
|
|
|
| 113 |
### `POST /api/players/refresh`
|
| 114 |
|
| 115 |
Refreshes missing, failed, or incomplete player cache entries. By default it skips successful cached players that already have match information.
|
| 116 |
+
Successful existing entries are preserved if a refresh fails because the DUPR token is invalid, DUPR is rate-limited, or the network/upstream API is unavailable.
|
| 117 |
|
| 118 |
Request body:
|
| 119 |
|