From 77989fbe35273af143c680edd2563701b952af37 Mon Sep 17 00:00:00 2001 From: Petr Vecera Date: Mon, 11 Sep 2023 23:32:26 +0200 Subject: [PATCH] Add Player Export API page (#254) * Add player export page * Correct status code --- __tests__/pages/api/playerExport.test.ts | 6 +- components/Header/components/OtherMenu.tsx | 13 ++- pages/about.tsx | 5 + pages/api/playerExport.ts | 41 ++++++--- pages/other/open-data.tsx | 16 +++- pages/other/player-export.tsx | 102 +++++++++++++++++++++ src/firebase/analytics.ts | 4 + src/routes.ts | 4 + 8 files changed, 169 insertions(+), 22 deletions(-) create mode 100644 pages/other/player-export.tsx diff --git a/__tests__/pages/api/playerExport.test.ts b/__tests__/pages/api/playerExport.test.ts index bb015d6d..76fa57f6 100644 --- a/__tests__/pages/api/playerExport.test.ts +++ b/__tests__/pages/api/playerExport.test.ts @@ -40,13 +40,13 @@ describe("playerExportAPIHandler", () => { }; await handler(req as unknown as NextApiRequest, res as unknown as NextApiResponse); expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: "profile id contains invalid params" }); + expect(res.json).toHaveBeenCalledWith({ error: "profile id contains invalid data" }); }); test("should return 500 if too many records requested", async () => { const req = { query: { - profileIDs: JSON.stringify(Array(101).fill(1)), + profileIDs: JSON.stringify(Array(51).fill(1)), }, }; const res = { @@ -54,7 +54,7 @@ describe("playerExportAPIHandler", () => { json: jest.fn(), }; await handler(req as unknown as NextApiRequest, res as unknown as NextApiResponse); - expect(res.status).toHaveBeenCalledWith(500); + expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ error: "Too many records requested" }); }); diff --git a/components/Header/components/OtherMenu.tsx b/components/Header/components/OtherMenu.tsx index eb74abde..1f0c9c05 100644 --- a/components/Header/components/OtherMenu.tsx +++ b/components/Header/components/OtherMenu.tsx @@ -9,7 +9,11 @@ import { Tooltip, } from "@mantine/core"; import Link from "next/link"; -import { getOpenDataRoute, getRankingTiersRoute } from "../../../src/routes"; +import { + getOpenDataRoute, + getPlayerExportRoute, + getRankingTiersRoute, +} from "../../../src/routes"; import React from "react"; import { IconActivity, @@ -17,6 +21,7 @@ import { IconChevronDown, IconDatabaseShare, IconAward, + IconUsers, } from "@tabler/icons-react"; const OtherMenu = ({ @@ -92,6 +97,12 @@ const OtherMenu = ({ Open Data + + + + Player Export API + + { About COH3 Stats + + <> diff --git a/pages/api/playerExport.ts b/pages/api/playerExport.ts index 6a66ecd3..94bda1fc 100644 --- a/pages/api/playerExport.ts +++ b/pages/api/playerExport.ts @@ -9,9 +9,10 @@ import { PlayerCardDataType } from "../../src/coh3/coh3-types"; import { json2csvAsync } from "json-2-csv"; import { NextApiRequest, NextApiResponse } from "next"; import { generateCSVObject } from "../../src/players/export"; +import { chunk } from "lodash"; const getPlayerInfo = async (profileID: string): Promise => { - return processPlayerInfoAPIResponse(await getPlayerCardInfo(profileID)); + return processPlayerInfoAPIResponse(await getPlayerCardInfo(profileID, true)); }; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -24,38 +25,48 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (typeof profileIDs !== "string") { - return res.status(400).json({ error: "profile id contains invalid params" }); + return res.status(400).json({ error: "profile id contains invalid data" }); } - let parsedTypes; + let parsedTypes: ["1v1", "2v2", "3v3", "4v4"]; if (types !== undefined && typeof types !== "string") { - return res.status(400).json({ error: "profile id contains invalid params" }); + return res.status(400).json({ error: "types contains invalid data" }); } + if (types !== undefined) { - parsedTypes = JSON.parse(types); + try { + parsedTypes = JSON.parse(types); + } catch (e) { + logger.error(e); + return res.status(400).json({ error: "error parsing the types data" }); + } + + if (!parsedTypes.every((type) => ["1v1", "2v2", "3v3", "4v4"].includes(type))) { + return res.status(400).json({ error: "parsedTypes contains invalid data" }); + } } - const arrayOfIds = JSON.parse(profileIDs); + const arrayOfIds: Array = JSON.parse(profileIDs); logger.log(`Going to parse ${arrayOfIds.length} ids`); logger.log(`List of IDs ${arrayOfIds}`); - if (arrayOfIds.length > 100) { - return res.status(500).json({ error: "Too many records requested" }); + if (arrayOfIds.length > 50) { + return res.status(400).json({ error: "Too many records requested" }); } const finalArray = []; - for (const profileId of arrayOfIds) { - const playerInfo = await getPlayerInfo(profileId); - const playerInfoAsCSVObject = generateCSVObject( - playerInfo, - profileId, - parsedTypes || undefined, + for (const singleChunk of chunk(arrayOfIds, 2)) { + const playerInfoPromises = singleChunk.map((profileId) => getPlayerInfo(profileId)); + const playerInfoArray = await Promise.all(playerInfoPromises); + const playerInfoAsCSVObjects = playerInfoArray.map((playerInfo, index) => + generateCSVObject(playerInfo, singleChunk[index], parsedTypes || undefined), ); - finalArray.push(playerInfoAsCSVObject); + finalArray.push(...playerInfoAsCSVObjects); } res .status(200) + .setHeader("Cache-Control", "public, max-age=60") .setHeader("content-type", "text/csv") .send(await json2csvAsync(finalArray, {})); } catch (e) { diff --git a/pages/other/open-data.tsx b/pages/other/open-data.tsx index 5e0ee019..68843c48 100644 --- a/pages/other/open-data.tsx +++ b/pages/other/open-data.tsx @@ -6,6 +6,7 @@ import { Container, Text, Title, Anchor, Code, Spoiler } from "@mantine/core"; import React, { useEffect } from "react"; import config from "../../config"; import { AnalyticsOpenDataPageView } from "../../src/firebase/analytics"; +import { generateKeywordsString } from "../../src/head-utils"; const codeForLeaderboards = `https://storage.coh3stats.com/leaderboards/{unixTimeStamp}/{unixTimeStamp}_{mode}_{faction}.json @@ -121,24 +122,33 @@ interface ProcessedMatchHistoryItem { // Check https://github.com/cohstats/coh3-stats/blob/master/src/coh3/coh3-raw-data.ts for additional details `; +const keywords = generateKeywordsString([ + "coh3 data", + "coh3 matches", + "download matches", + "coh3 match api", + "coh3 leaderboards api", +]); + /** * This is example page you can find it by going on ur /example * @constructor */ -const About: NextPage = () => { +const OpenData: NextPage = () => { useEffect(() => { AnalyticsOpenDataPageView(); }, []); return (
- {/*This is custom HEAD overwrites the default one*/} COH3 Stats - Open Data + + <> @@ -259,4 +269,4 @@ const About: NextPage = () => { ); }; -export default About; +export default OpenData; diff --git a/pages/other/player-export.tsx b/pages/other/player-export.tsx new file mode 100644 index 00000000..a6b89051 --- /dev/null +++ b/pages/other/player-export.tsx @@ -0,0 +1,102 @@ +import { NextPage } from "next"; +import React, { useEffect } from "react"; +import { AnalyticsPlayerExportPageView } from "../../src/firebase/analytics"; +import Head from "next/head"; +import { Anchor, Code, Container, Space, Text, Title } from "@mantine/core"; +import { generateKeywordsString } from "../../src/head-utils"; + +const exampleOutput = `alias,relic_id,steam_id,1v1_axis_elo,1v1_allies_elo,german_1v1_rank,german_1v1_elo,german_1v1_total,american_1v1_rank,american_1v1_elo,american_1v1_total,dak_1v1_rank,dak_1v1_elo,dak_1v1_total,british_1v1_rank,british_1v1_elo,british_1v1_total +Isildur,3705,76561198018614046,1432,1475,-1,1432,23,-1,1307,14,-1,1417,13,-1,1475,34 +Rei,871,76561198404414770,1547,1607,43,1547,106,33,1602,139,26,1510,76,-1,1607,87 +jibber,6219,76561198090318538,1717,1679,10,1717,229,21,1657,187,-1,1594,232,-1,1679,100 +Luvnest,108833,76561197982704567,1543,1634,45,1543,146,37,1593,101,67,1400,85,18,1634,97 +elpern,61495,76561198019498694,1681,1717,-1,1681,139,-1,1699,178,-1,1611,100,-1,1717,156 +IncaUna,1287,76561198152399446,1557,1691,-1,1557,24,-1,1218,5,-1,1510,46,-1,1691,157`; + +const keywords = generateKeywordsString(["coh3 players", "export", "csv"]); + +const PlayerExport: NextPage = () => { + useEffect(() => { + AnalyticsPlayerExportPageView(); + }, []); + + return ( + <> + + COH3 Stats - Player Export in CSV + + + + + <> + + + COH3 Stats - Player Export in CSV + + + API for tournament organizers. + + + This API gives you access to players leaderboard stats in CSV format. Which you can + easily import in Excel sheet. + + + + Export the data via this link: + {`https://coh3stats.com/api/playerExport?types=["1v1"]&profileIDs=[3705,871,6219,108833,61495,1287]`} + + + Parameter types can be {`["1v1", "2v2", "3v3", "4v4"]`}. +
+ Parameter profileIDs is an array of Relic profile IDs. You can find them + on COH3 Stats player cards. +
+ + + Example how to import the data into Google Sheets is{" "} + + here + + . + + + + Example output: + + {exampleOutput} + + + + In case you need the data for your tournament in different format or you have any + other questions, let us know on Discord. + + + Shout-out for coh3stats.com at your tournament would be awesome. Thank you + + + + Please note that this API is designed for use in tournaments or with a limited number + of players. + + {" "} + It is not intended for programmatic use. + {" "} + For any API collaboration head over to our Discord. + +
+ + + ); +}; + +export default PlayerExport; diff --git a/src/firebase/analytics.ts b/src/firebase/analytics.ts index e11edcc7..4327bcb6 100644 --- a/src/firebase/analytics.ts +++ b/src/firebase/analytics.ts @@ -44,6 +44,10 @@ export const AnalyticsOpenDataPageView = (): void => { logFBEvent("open_data_view"); }; +export const AnalyticsPlayerExportPageView = (): void => { + logFBEvent("player_export_view"); +}; + export const SearchPageView = (): void => { logFBEvent("search_view"); }; diff --git a/src/routes.ts b/src/routes.ts index fbdce23c..d3a975f4 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -95,6 +95,10 @@ export const getOpenDataRoute = () => { return encodeURI(`/other/open-data`); }; +export const getPlayerExportRoute = () => { + return encodeURI(`/other/player-export`); +}; + export const getRankingTiersRoute = () => { return encodeURI(`/other/ranking-tiers`); };