/// <reference types="@userscripters/stackexchange-api-types" />
const API_BASE = "https://api.stackexchange.com";
const API_VER = 2.3;
const API_KEY = "46zkb16JUl3s3jZdIsJDVA((";
/**
* @summary delays script execution
* @param {number} [ms] milliseconds to delay for
* @returns {Promise<void>}
*/
const delay = (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* @summary makes a table cell
* @param {string| Node} content
* @returns {HTMLTableCellElement}
*/
const makeCell = (content) => {
const td = document.createElement("td");
td.append(content);
return td;
};
/**
* @summary makes a table row
* @param {Array<string | Node>} columns
* @returns {HTMLTableRowElement}
*/
const makeRow = (...columns) => {
const tr = document.createElement("tr");
const cells = columns.map(makeCell);
tr.append(...cells);
return tr;
};
const makeLink = (url, label) => {
const a = document.createElement("a");
a.target = "_blank";
a.href = url;
a.innerText = label;
return a;
};
/**
* @see https://api.stackexchange.com/docs/questions-on-users
*
* @summary gets question info by user id
* @param {number} id user id
* @param {{ site?: string, page?: number, ...rest }} [options] request configuration
* @returns {Promise<StackExchangeAPI.Question[]>}
*/
const getQuestions = async (id, { site = "stackoverflow", page = 1, ...rest }) => {
const url = new URL(`${API_BASE}/${API_VER}/users/${id}/questions`);
url.search = new URLSearchParams({
site,
page: page.toString(),
...rest,
}).toString();
const res = await fetch(url.toString());
if (!res.ok) return [];
const { items = [], has_more = false, backoff } = await res.json();
if (backoff) {
await delay(backoff * 1e3);
return getQuestions(id, { site, page, ...rest });
}
if (has_more) {
items.push(
...(await getQuestions(id, {
site,
page: page + 1,
...rest,
}))
);
}
return items;
};
/**
* @see https://api.stackexchange.com/docs/answers-on-questions
*
* @summary gets answers by question ids
* @param {number[]} ids user id
* @param {{ site?: string, page?: number, ...rest }} [options] request configuration
* @returns {Promise<StackExchangeAPI.Answer[]>}
*/
const getAnswers = async (ids, { site = "stackoverflow", page = 1, ...rest }) => {
const url = new URL(`${API_BASE}/${API_VER}/questions/${ids.join(";")}/answers`);
url.search = new URLSearchParams({
site,
page: page.toString(),
...rest,
}).toString();
const res = await fetch(url.toString());
if (!res.ok) return [];
const { items = [], has_more = false, backoff } = await res.json();
if (backoff) {
await delay(backoff * 1e3);
return getAnswers(ids, { site, page, ...rest });
}
if (has_more) {
items.push(
...(await getAnswers(ids, {
site,
page: page + 1,
...rest,
}))
);
}
return items;
};
/**
* @template {unknown} T
*
* @summary splits an array into chunks
* @param {T[]} arr array to split
* @param {number} size chunk size
* @returns {T[][]}
*/
const chunkify = (arr, size) => {
/** @type {T[][]} */
const output = [];
arr.forEach((e, i) => {
let chunk = output[output.length - 1];
i % size ? chunk.push(e) : output.push([e]);
});
return output;
};
/**
* @template {object} T
*
* @summary get a sum of items by key
* @param {T[]} items items to process
* @param {keyof T} key key to use for the ssum
* @returns {number}
*/
const getSum = (items, key) => items.reduce((a, c) => a + (c[key] || 0), 0);
/**
/**
* @template {object} T
*
* @summary get a total of items by key
* @param {T[]} items items to process
* @param {keyof T} key key to use for the total
* @returns {number}
*/
const getTotal = (items, key) => items.reduce((a, c) => a + (c[key] ? 1 : 0), 0);
/**
* @summary sums score of all posts
* @param {Array<StackExchangeAPI.Post>} posts posts to process
* @returns {number}
*/
const getTotalScore = (posts) => getSum(posts, "score");
window.addEventListener("load", () => {
const accountInput = document.getElementById("account_id");
const submit = document.getElementById("search");
const siteInput = document.getElementById("site");
const progress = document.getElementById("status");
progress.value = 0;
submit.addEventListener("click", async () => {
const { value: site } = siteInput;
const { value: userId } = accountInput;
const report = document.querySelector("#preview > tbody");
const questions = await getQuestions(userId, { key: API_KEY, site });
progress.value = 25;
const questionIds = questions.map(({ question_id }) => question_id);
const chunks = chunkify(questionIds, 100); // max number of ids is 100
const { length: numChunks } = chunks;
const progressValuePerChunk = 75 / numChunks;
/** @type {Record<string, { answers: StackExchangeAPI.Answer[], link: string, name: string }>} */
const answers = {};
for (const chunk of chunks) {
const items = await getAnswers(chunk, { key: API_KEY, site, filter: "!nKzQUREjAc" });
items.forEach((answer) => {
const { owner: { user_id, display_name, link } } = answer;
if (!answers[user_id]) answers[user_id] = { answers: [] };
const userAnswers = answers[user_id];
userAnswers.answers.push(answer);
userAnswers.link = link;
userAnswers.name = display_name;
});
progress.value += progressValuePerChunk;
}
report.querySelectorAll("tr").forEach((r) => r.remove());
const answersByUser = Object.values(answers);
answersByUser.sort((a, b) => getTotalScore(b.answers) - getTotalScore(a.answers));
const upvoteRep = 10;
const acceptRep = 15;
const rows = answersByUser.flatMap(({ link, name, answers }) => {
const userLink = makeLink(link, name);
const totalAnswers = answers.length;
const totalAccepted = getTotal(answers, "is_accepted");
const totalWikis = getTotal(answers.filter((a) => a.community_owned_date), "community_owned_date");
const totalBounties = getSum(answers, "awarded_bounty_amount");
const totalScore = getTotalScore(answers);
const totalNonWikiScore = getTotalScore(answers.filter((a) => !a.community_owned_date));
const repEstimate = totalNonWikiScore * upvoteRep + totalAccepted * acceptRep + totalBounties;
return makeRow(
userLink,
totalAnswers,
totalWikis,
totalAccepted,
totalBounties,
totalScore,
repEstimate
);
});
report.append(...rows);
report.closest("table").classList.remove("hidden");
progress.value = 100;
});
});
label {
display: block;
font-weight: 600;
}
caption,
label {
font-weight: 600;
margin-bottom: 0.5vh;
}
table {
margin-top: 4vh;
border-collapse: collapse;
}
th,
td {
padding: 2vh 2vw;
border: 1px solid lightgray;
}
.hidden {
display: none;
}
progress {
margin-top: 2vh;
height: 20px;
border-radius: 0;
}
<form>
<label for="site">Site API Slug</label>
<input id="site" type="text" title="Site API slug" placeholder="stackoverflow" value="stackoverflow" />
<label for="account_id">User Account Id</label>
<input id="account_id" type="text" title="Account Id" placeholder="12345" />
<button id="search" type="button">Get Info</button>
</form>
<progress id="status" max="100" value="0"></progress>
<table id="preview" class="hidden" cellspacing="0">
<caption>Stack Exchange Post Activity</caption>
<thead>
<tr>
<th>Account</th>
<th>Answers</th>
<th>Wikis</th>
<th>Accepted</th>
<th>Bounty rep</th>
<th>Score</th>
<th>Rep Estimate</th>
</tr>
</thead>
<tbody>
</tbody>
</table>