Initial commit
This commit is contained in:
commit
8f92e078bf
4 changed files with 348 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
auth.txt
|
||||||
|
out.csv
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"deno.enable": true
|
||||||
|
}
|
221
cli.ts
Normal file
221
cli.ts
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
import {
|
||||||
|
DOMParser,
|
||||||
|
} from "https://deno.land/x/deno_dom@v0.1.45/deno-dom-wasm.ts";
|
||||||
|
import { writeCSV } from "https://deno.land/x/csv@v0.9.2/mod.ts";
|
||||||
|
|
||||||
|
import {
|
||||||
|
NSDAMemberLookup,
|
||||||
|
NSDAMemberPointEntry,
|
||||||
|
NSDAMemberSearchResult,
|
||||||
|
TabroomEntry,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
|
// Load auth
|
||||||
|
const nsdaAuth = await Deno.readFile("./auth.txt").then((a) =>
|
||||||
|
new TextDecoder().decode(a)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch and parse tabroom USX entries
|
||||||
|
const tabroomHtml = await fetch(
|
||||||
|
"https://www.tabroom.com/index/tourn/fields.mhtml?tourn_id=30061&event_id=279207",
|
||||||
|
).then((r) => r.text());
|
||||||
|
|
||||||
|
const dom = new DOMParser().parseFromString(
|
||||||
|
tabroomHtml,
|
||||||
|
"text/html",
|
||||||
|
)!;
|
||||||
|
|
||||||
|
const entries: TabroomEntry[] = [];
|
||||||
|
|
||||||
|
for (const row of dom.querySelectorAll("tr")) {
|
||||||
|
const [institution, location, entry, code] = [...row.childNodes]
|
||||||
|
.filter((n) => n.nodeName === "TD")
|
||||||
|
.map((n) => n.textContent.trim());
|
||||||
|
// Skip invalid entries
|
||||||
|
if (!institution || !location || !entry || !code) continue;
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
institution,
|
||||||
|
location,
|
||||||
|
entry,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup on NSDA
|
||||||
|
const nsdaSearchResults = await Promise
|
||||||
|
.all(
|
||||||
|
entries.map(async (e) => {
|
||||||
|
let nsdaLookup: NSDAMemberSearchResult[] = await fetch(
|
||||||
|
`https://api.speechanddebate.org/v2/search?${
|
||||||
|
new URLSearchParams({
|
||||||
|
q: e.entry,
|
||||||
|
type: "members",
|
||||||
|
}).toString()
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
authorization: nsdaAuth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).then((r) => r.json());
|
||||||
|
|
||||||
|
// Do our best to filter the results down to one
|
||||||
|
// Start by filtering anything but HS competitors and status === 1
|
||||||
|
// (status 2 appeared on a duplicate, so I assume that marks inactive entries?)
|
||||||
|
nsdaLookup = nsdaLookup.filter((ee) =>
|
||||||
|
ee.realm === "hs" && ee.status === 1
|
||||||
|
);
|
||||||
|
// Then, if that doesn't work, filter by state
|
||||||
|
if (nsdaLookup.length > 1) {
|
||||||
|
nsdaLookup = nsdaLookup.filter((ee) =>
|
||||||
|
ee.school_state.toLowerCase() ===
|
||||||
|
e.location.replace("/US", "").toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Finally, if none of the past filters worked, attempt a school filter
|
||||||
|
let schoolFilterUsed = false;
|
||||||
|
if (nsdaLookup.length > 1) {
|
||||||
|
schoolFilterUsed = true;
|
||||||
|
|
||||||
|
nsdaLookup = nsdaLookup.filter((ee) =>
|
||||||
|
ee.school_name.toLowerCase().includes(e.institution.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nsdaLookup.length < 1) {
|
||||||
|
console.error(
|
||||||
|
`No results found for entry "${e.entry}" (with code "${e.code}"), from ${e.institution} in ${
|
||||||
|
e.location.replace("/US", "")
|
||||||
|
}.`,
|
||||||
|
);
|
||||||
|
if (schoolFilterUsed) console.error("SCHOOL-BASED FILTER WAS USED!");
|
||||||
|
return null;
|
||||||
|
} else if (nsdaLookup.length > 1) {
|
||||||
|
console.error(
|
||||||
|
`Multiple entries found for entry "${e.entry}" (with code "${e.code}"), from ${e.institution} in ${
|
||||||
|
e.location.replace("/US", "")
|
||||||
|
}!`,
|
||||||
|
);
|
||||||
|
console.error("---------------------------------------------");
|
||||||
|
console.error(nsdaLookup);
|
||||||
|
console.error("---------------------------------------------");
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return { tabroom: e, nsdaSearchResult: nsdaLookup[0] };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then((p) =>
|
||||||
|
p.filter((e) => e !== null) as {
|
||||||
|
tabroom: TabroomEntry;
|
||||||
|
nsdaSearchResult: NSDAMemberSearchResult;
|
||||||
|
}[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert NSDA searches to true NSDA member objects and point entries to get actual information
|
||||||
|
const nsdaLookups = await Promise.all(
|
||||||
|
nsdaSearchResults.map(async ({ tabroom, nsdaSearchResult }) => ({
|
||||||
|
tabroom,
|
||||||
|
nsdaSearchResult,
|
||||||
|
nsdaMemberLookup: await fetch(
|
||||||
|
"https://api.speechanddebate.org/v2/members/" + nsdaSearchResult.id,
|
||||||
|
{ headers: { authorization: nsdaAuth } },
|
||||||
|
).then((r) => r.json()) as NSDAMemberLookup,
|
||||||
|
nsdaPointsLookup: await fetch(
|
||||||
|
"https://api.speechanddebate.org/v2/members/" + nsdaSearchResult.id +
|
||||||
|
"/points",
|
||||||
|
{ headers: { authorization: nsdaAuth } },
|
||||||
|
).then((r) => r.json()) as NSDAMemberPointEntry[],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Analyze and display aggregate fetched information
|
||||||
|
console.log(
|
||||||
|
`Represented states:
|
||||||
|
${
|
||||||
|
Object.entries(nsdaLookups.reduce(
|
||||||
|
(acc, l) => ({
|
||||||
|
...acc,
|
||||||
|
[l.nsdaSearchResult.school_state]:
|
||||||
|
(acc[l.nsdaSearchResult.school_state] ?? 0) + 1,
|
||||||
|
}),
|
||||||
|
{} as Record<string, number>,
|
||||||
|
)).sort((a, b) => b[1] - a[1]).map(([state, n]) => "- " + state + ": " + n)
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
Total entries: ${nsdaLookups.length}
|
||||||
|
Total schools: ${
|
||||||
|
[...new Set(nsdaLookups.map((l) => l.nsdaSearchResult.school_name))].length
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect information into a spreadsheet format
|
||||||
|
const sheet = [
|
||||||
|
[
|
||||||
|
"Last points entry",
|
||||||
|
"First name",
|
||||||
|
"Middle name",
|
||||||
|
"Last name",
|
||||||
|
"State",
|
||||||
|
"School",
|
||||||
|
"Extemp Points",
|
||||||
|
"Year Extemp Points",
|
||||||
|
"Extemp tournaments",
|
||||||
|
"Year Extemp Tournaments",
|
||||||
|
"Average Placing",
|
||||||
|
"Year Average Placing",
|
||||||
|
],
|
||||||
|
...nsdaLookups.map((l) => [
|
||||||
|
l.nsdaMemberLookup.last_points_entry,
|
||||||
|
l.nsdaMemberLookup.first,
|
||||||
|
l.nsdaMemberLookup.middle ?? "",
|
||||||
|
l.nsdaMemberLookup.last,
|
||||||
|
l.nsdaSearchResult.school_state,
|
||||||
|
l.nsdaSearchResult.school_name,
|
||||||
|
l.nsdaPointsLookup.filter((e) =>
|
||||||
|
e.category_id === 202 /* Extemporaneous speech event ID */
|
||||||
|
).reduce((acc, e) => acc + e.points, 0).toString(),
|
||||||
|
l.nsdaPointsLookup.filter((e) =>
|
||||||
|
e.category_id === 202 && /* Extemporaneous speech event ID */
|
||||||
|
new Date(e.start).getTime() > new Date(2023, 6, 0).getTime()
|
||||||
|
).reduce((acc, e) => acc + e.points, 0).toString(),
|
||||||
|
l.nsdaPointsLookup.filter((e) =>
|
||||||
|
e.category_id === 202 /* Extemporaneous speech event ID */
|
||||||
|
).reduce(
|
||||||
|
(acc, e) => [...acc, ...(acc.includes(e.tourn_id) ? [] : [e.tourn_id])],
|
||||||
|
[] as number[],
|
||||||
|
).length.toString(),
|
||||||
|
l.nsdaPointsLookup.filter((e) =>
|
||||||
|
e.category_id === 202 && /* Extemporaneous speech event ID */
|
||||||
|
new Date(e.start).getTime() > new Date(2023, 6, 0).getTime()
|
||||||
|
).reduce(
|
||||||
|
(acc, e) => [...acc, ...(acc.includes(e.tourn_id) ? [] : [e.tourn_id])],
|
||||||
|
[] as number[],
|
||||||
|
).length.toString(),
|
||||||
|
(Math.round(
|
||||||
|
l.nsdaPointsLookup.filter((e) =>
|
||||||
|
e.category_id === 202 /* Extemporaneous speech event ID */
|
||||||
|
).flatMap((e) => (JSON.parse(e.result) as { ranks: number[] }).ranks)
|
||||||
|
.reduce((acc, v, _, a) => (acc + v / a.length), 0) * 1000,
|
||||||
|
) / 1000).toString(),
|
||||||
|
(Math.round(
|
||||||
|
l.nsdaPointsLookup.filter((e) =>
|
||||||
|
e.category_id === 202 && /* Extemporaneous speech event ID */
|
||||||
|
new Date(e.start).getTime() > new Date(2023, 6, 0).getTime()
|
||||||
|
).flatMap((e) => (JSON.parse(e.result) as { ranks: number[] }).ranks)
|
||||||
|
.reduce((acc, v, _, a) => (acc + v / a.length), 0) * 1000,
|
||||||
|
) / 1000).toString(),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
await writeCSV(
|
||||||
|
await Deno.open("./out.csv", {
|
||||||
|
write: true,
|
||||||
|
create: true,
|
||||||
|
truncate: true,
|
||||||
|
}),
|
||||||
|
sheet,
|
||||||
|
{
|
||||||
|
columnSeparator: "\t",
|
||||||
|
},
|
||||||
|
);
|
122
types.ts
Normal file
122
types.ts
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
export interface NSDAMemberSearchResult {
|
||||||
|
id: number;
|
||||||
|
first: string;
|
||||||
|
last: string;
|
||||||
|
role: string;
|
||||||
|
school_id: number;
|
||||||
|
status: number;
|
||||||
|
realm: string;
|
||||||
|
school_name: string;
|
||||||
|
school_state: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabroomEntry {
|
||||||
|
institution: string;
|
||||||
|
location: string;
|
||||||
|
entry: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NSDAMemberLookup {
|
||||||
|
person_id: number;
|
||||||
|
first: string;
|
||||||
|
middle: string | null;
|
||||||
|
last: string;
|
||||||
|
disabled: number;
|
||||||
|
created_at: string;
|
||||||
|
points: number;
|
||||||
|
points_service: number;
|
||||||
|
points_service_this_year: number;
|
||||||
|
points_this_year: number;
|
||||||
|
points_last_year: number;
|
||||||
|
last_points_entry: string;
|
||||||
|
citation_points: number;
|
||||||
|
degree_id: number;
|
||||||
|
degree_name: string;
|
||||||
|
to_next_degree: number;
|
||||||
|
diamonds: number | null;
|
||||||
|
"3_diamond": boolean;
|
||||||
|
hof: boolean;
|
||||||
|
aaa: boolean;
|
||||||
|
active: boolean;
|
||||||
|
paid: boolean;
|
||||||
|
paid_latest: string;
|
||||||
|
school_paid: boolean;
|
||||||
|
active_student: boolean;
|
||||||
|
degrees: NSDAMemberDegree[];
|
||||||
|
honors: NSDAMemberHonor[];
|
||||||
|
citations: unknown[];
|
||||||
|
districts_eligible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NSDAMemberDegree {
|
||||||
|
member_honor_id: number;
|
||||||
|
person_id: number;
|
||||||
|
honor_id: number;
|
||||||
|
description: string;
|
||||||
|
type: string;
|
||||||
|
school_id: number;
|
||||||
|
school_name: string;
|
||||||
|
district_id: number | null;
|
||||||
|
district_name: string | null;
|
||||||
|
state: string | null;
|
||||||
|
points: number;
|
||||||
|
start: string;
|
||||||
|
end: unknown;
|
||||||
|
status: string;
|
||||||
|
recognized: unknown;
|
||||||
|
note: string | null;
|
||||||
|
created_at: string;
|
||||||
|
created_by_id: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NSDAMemberHonor {
|
||||||
|
member_honor_id: number;
|
||||||
|
person_id: number;
|
||||||
|
honor_id: number;
|
||||||
|
description: string;
|
||||||
|
type: string;
|
||||||
|
school_id: number;
|
||||||
|
school_name: string;
|
||||||
|
district_id: number | null;
|
||||||
|
district_name: string | null;
|
||||||
|
state: string | null;
|
||||||
|
points: number | null;
|
||||||
|
start: string;
|
||||||
|
end: unknown;
|
||||||
|
status: string;
|
||||||
|
recognized: unknown;
|
||||||
|
note: string | null;
|
||||||
|
created_at: string;
|
||||||
|
created_by_id: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NSDAMemberPointEntry {
|
||||||
|
id: number;
|
||||||
|
student_id: number;
|
||||||
|
student_name: string;
|
||||||
|
coach_id: number;
|
||||||
|
coach_name: string;
|
||||||
|
school_id: number;
|
||||||
|
category_id: number;
|
||||||
|
points: number;
|
||||||
|
result: string;
|
||||||
|
description: string | null;
|
||||||
|
autopost: boolean;
|
||||||
|
districts: boolean;
|
||||||
|
nationals: boolean;
|
||||||
|
tourn_id: number;
|
||||||
|
tourn_name: string;
|
||||||
|
location: string;
|
||||||
|
state: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
source: string;
|
||||||
|
status: number;
|
||||||
|
created_at: string;
|
||||||
|
created_by_id: number | null;
|
||||||
|
created_by: string;
|
||||||
|
}
|
Loading…
Reference in a new issue