Greasy Fork is available in English.
GLB D-League Challenge stat gatherer - Inspired by a legacy community script for GLB D-League stat collection. Original author unknown. Current version substantially rewritten.
// ==UserScript==
// @name DLC (TH v1)
// @namespace TruthHammer
// @description GLB D-League Challenge stat gatherer - Inspired by a legacy community script for GLB D-League stat collection. Original author unknown. Current version substantially rewritten.
// @match http://glb.warriorgeneral.com/game/home*
// @match https://glb.warriorgeneral.com/game/home*
// @version 1.0.1
// @license MIT
// @grant none
// ==/UserScript==
// If anybody knows who the original author is, let me know, so I can add proper credit.
(function () {
'use strict';
// --- SEASON CONFIG (UPDATE THESE EACH SEASON FIRST) ---
const playerList = [
4906276,
4907129,
4906359,
4908360,
4908361,
4907151,
4908178,
4908319,
4907636,
4906787,
4906149,
4907360,
4907543,
4906672,
4906164,
4907199,
4907091,
4906151,
4906091,
4906527,
4906065,
4906069,
4906072,
4906499,
4908329,
4908434,
];
const season = 119;
// --- END OF SEASON CONFIG ---
// Change this to true to enable console logging for troubleshooting.
const enableLogging = false;
// Available stat types (selected in UI)
const allStatTypes = [
'Passing',
'Rushing',
'Receiving',
'Kicking',
'Punting',
'Kick/Punt Return',
'Special Teams',
'Offensive Line',
'Defense',
];
let statTypes = allStatTypes.slice();
// --- Config maps (editable per season/contest rules) ---
const headings = {};
headings['Passing'] = ['Plays','Comp','Att','Yds','Pct','Y/A','Hurry','Sack','SackYd','BadTh','Drop','Int','TD','Rating'];
headings['Receiving'] = ['Plays','Targ','Rec','Yds','Avg','YAC','TD','Drop','TargRZ','Targ3d','Fum','FumL'];
headings['Rushing'] = ['Plays','Rush','Yds','Avg','TD','BrTk','TFL','Fum','FumL'];
headings['Kicking'] = ['FGM','FGA','0-19','20-29','30-39','40-49','50+','XPM','XPA','Points','KO','TB','Yds','Avg'];
headings['Punting'] = ['Punts','Yds','Avg','TB','CofCrn','Inside5','Inside10','Inside20'];
headings['Kick/Punt Return'] = ['KR','KYds','KAvg','KTD','PR','PYds','PAvg','PTD'];
headings['Special Teams'] = ['Plays','Tk','MsTk','FFum','FRec','FumRTD','Pnkd','BrTk','Fum','FumL'];
headings['Offensive Line'] = ['Plays','Pancakes','RevPnkd','HryAlw','SackAlw'];
headings['Defense'] = [
'Ply','Tk','MsTk','Sack','Hry','TFL','FFum','FumR','RecAlw','KL','PD','Int','DefTD'
];
const sortable = {};
sortable['Passing'] = 4;
sortable['Rushing'] = 3;
sortable['Receiving'] = 3;
sortable['Kicking'] = 10;
sortable['Punting'] = 2;
sortable['Kick/Punt Return'] = 2;
sortable['Special Teams'] = 2;
sortable['Offensive Line'] = 2;
sortable['Defense'] = 2;
const points = {};
points['Passing'] = [0,0,0,0.04,0,0,0,0,0,0,0,-1,4,0];
points['Defense'] = [0,1,0,3,1,1,4,3,0,1,1,5,6]; // FS/SS/CB default from original
points['Receiving'] = [0,0.5,0.1,0,6,0,0,0,0,0,0,0];
points['Rushing'] = [0,0,0.4,0,6,0.5,0,0,0];
points['Kicking'] = [0,0,0,0,0,0,0,0,0,0,0,0,0,0];
points['Punting'] = [0,0,0,0,0,0,0,0];
points['Special Teams'] = [0,0,0,0,0,0,0,0,0,0];
points['Offensive Line'] = [0,1.25,-1.25,-0.5,-4];
points['Kick/Punt Return'] = [0,0,0,4,0,0,0,4];
// Logger-only ignore list for known extra columns you intentionally do not track.
const ignoredUnknownHeadersByType = {
'Defense': ['SYds', 'IYds', 'Targ', 'Pnkd', 'RevPnk'],
'Rushing': ['RushRZ'],
};
// --- STATE ---
const playerSet = new Set(playerList.map(String));
const baseUrl = `${location.protocol}//${location.host}`;
let gamelinks = [];
let players = {}; // map pid -> player object
let playerAgentNamesById = {};
let playerAgentUserIdsById = {};
let rowCountsByType = {};
let unknownHeadersByType = {};
let lastCollectedWeek = null;
let seasonMismatchWarned = false;
let collectionCancelRequested = false;
let activeCollectionAbortController = null;
let outputNameFilter = '';
let outputAgentFilter = '';
let outputShowEmptySections = false;
let outputSelectedStatTypes = new Set(allStatTypes);
let outputActiveTab = 'tables';
let outputCollapsedTypes = new Set();
const RANK_TIE_EPSILON = 1e-9;
let pointsConfigWarningsLogged = false;
let seasonTotalWarningShown = false;
function log(...args) {
if (enableLogging) console.log(...args);
}
function warn(...args) {
if (enableLogging) console.warn(...args);
}
function err(...args) {
if (enableLogging) console.error(...args);
}
function fatal(...args) {
console.error(...args);
}
// Boot UI
window.setTimeout(() => {
injectControls();
}, 1000);
function ensureUiStyles() {
if (document.getElementById('dlc_v2_styles')) return;
const style = document.createElement('style');
style.id = 'dlc_v2_styles';
style.textContent = `
#dlc_v2_controls {
background: #f7f7f7;
border: 1px solid #cfcfcf;
border-radius: 4px;
padding: 8px !important;
margin-top: 6px;
}
#dlc_v2_controls input[type="button"] {
margin-right: 2px;
}
#dlc_output_root {
margin-top: 12px;
}
.dlc-summary {
background: #f5f8ff;
border: 1px solid #cfd8ea;
border-radius: 4px;
padding: 6px 8px;
font-weight: bold;
}
.dlc-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
margin: 8px 0 6px;
padding: 6px 8px;
border: 1px solid #d7d7d7;
border-radius: 4px;
background: #fbfbfb;
}
.dlc-toolbar label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
}
.dlc-toolbar input[type="text"] {
width: 180px;
padding: 2px 4px;
}
.dlc-tabs {
margin-top: 8px;
}
.dlc-tab-buttons {
display: flex;
gap: 4px;
margin-bottom: 0;
}
.dlc-tab-btn {
border: 1px solid #c8c8c8;
background: #f3f3f3;
padding: 4px 8px;
cursor: pointer;
border-radius: 4px 4px 0 0;
}
.dlc-tab-btn.active {
background: #fff;
border-bottom-color: #fff;
font-weight: bold;
}
.dlc-tab-panel {
border: 1px solid #d7d7d7;
background: #fff;
padding: 8px;
}
.dlc-forum-placeholder {
color: #555;
font-size: 12px;
}
.dlc-forum-tools {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 6px;
}
.dlc-forum-tools span {
font-size: 12px;
color: #555;
}
.dlc-forum-text {
width: 100%;
min-height: 260px;
box-sizing: border-box;
font-family: Arial, sans-serif;
font-size: 12px;
}
.dlc-type-section {
margin: 12px 0 16px;
border: 1px solid #d7d7d7;
border-radius: 4px;
background: #fff;
}
.dlc-type-header {
padding: 7px 9px;
background: #eef2f7;
border-bottom: 1px solid #d7d7d7;
font-weight: bold;
cursor: pointer;
user-select: none;
}
.dlc-type-section:not([open]) .dlc-type-header {
border-bottom: none;
}
.dlc-empty-note {
padding: 8px 10px;
color: #666;
font-style: italic;
}
.dlc-table-wrap {
overflow-x: auto;
max-width: 100%;
}
.dlc-stats-table {
width: 100%;
border-collapse: collapse;
margin: 0;
font-size: 12px;
}
.dlc-stats-table th,
.dlc-stats-table td {
border: 1px solid #dcdcdc;
padding: 4px 6px;
white-space: nowrap;
}
.dlc-stats-table thead th {
position: sticky;
top: 0;
background: #f0f0f0;
z-index: 1;
}
.dlc-stats-table tbody tr:nth-child(even) {
background: #fafafa;
}
.dlc-stats-table tbody tr:hover {
background: #eef7ff;
}
.dlc-stats-table td:nth-child(1),
.dlc-stats-table td:nth-child(2),
.dlc-stats-table td:nth-child(3),
.dlc-stats-table td:nth-child(4) {
text-align: left;
}
.dlc-stats-table td {
text-align: right;
}
`;
document.head.appendChild(style);
}
function injectControls() {
if (document.getElementById('dlc_v2_controls')) return;
ensureUiStyles();
const seasonInfo = detectSeasonInfoFromPage();
const div = document.createElement('div');
div.id = 'dlc_v2_controls';
div.style.clear = 'both';
div.style.padding = '6px 0';
if (Number.isFinite(seasonInfo.day) && seasonInfo.day < 0) {
const note = document.createElement('div');
note.style.padding = '4px 0';
note.textContent =
'DLC collection will be available after the season starts. Before running this season, update the script config at the top: season and playerList.';
div.appendChild(note);
const content = document.getElementById('content') || document.body;
content.parentNode.insertBefore(div, content.nextSibling);
warnIfSeasonMismatch();
return;
}
const btn = document.createElement('input');
btn.value = 'Collect';
btn.type = 'button';
btn.id = 'dlc_collect';
btn.addEventListener('click', collect, false);
div.appendChild(btn);
const csvBtn = document.createElement('input');
csvBtn.value = 'Export CSV';
csvBtn.type = 'button';
csvBtn.id = 'dlc_export_csv';
csvBtn.disabled = true;
csvBtn.style.marginLeft = '6px';
csvBtn.addEventListener('click', exportCsv, false);
div.appendChild(csvBtn);
const weekText = document.createElement('span');
weekText.textContent = ' Week: ';
div.appendChild(weekText);
const weekSelect = document.createElement('select');
weekSelect.id = 'week_input';
weekSelect.style.minWidth = '56px';
populateWeekOptions(weekSelect, seasonInfo);
weekSelect.addEventListener('change', () => {
const collectBtn = document.getElementById('dlc_collect');
if (collectBtn) collectBtn.disabled = false;
});
div.appendChild(weekSelect);
const hint = document.createElement('span');
hint.style.marginLeft = '10px';
hint.style.opacity = '0.8';
hint.textContent = `Season ${season} - ${playerList.length} Players Configured`;
div.appendChild(hint);
const modeRow = document.createElement('div');
modeRow.style.marginTop = '6px';
modeRow.style.display = 'flex';
modeRow.style.flexWrap = 'wrap';
modeRow.style.gap = '10px';
modeRow.style.alignItems = 'center';
const modeLabel = document.createElement('span');
modeLabel.textContent = 'Collection scope:';
modeLabel.style.fontSize = '12px';
modeRow.appendChild(modeLabel);
const singleLabel = document.createElement('label');
singleLabel.style.display = 'inline-flex';
singleLabel.style.alignItems = 'center';
singleLabel.style.gap = '3px';
singleLabel.style.fontSize = '12px';
const singleRadio = document.createElement('input');
singleRadio.type = 'radio';
singleRadio.name = 'dlc_week_mode';
singleRadio.id = 'dlc_week_mode_single';
singleRadio.value = 'single';
singleRadio.checked = true;
singleRadio.addEventListener('change', onCollectionModeChanged, false);
singleLabel.appendChild(singleRadio);
singleLabel.appendChild(document.createTextNode('Selected week only'));
modeRow.appendChild(singleLabel);
const throughLabel = document.createElement('label');
throughLabel.style.display = 'inline-flex';
throughLabel.style.alignItems = 'center';
throughLabel.style.gap = '3px';
throughLabel.style.fontSize = '12px';
const throughRadio = document.createElement('input');
throughRadio.type = 'radio';
throughRadio.name = 'dlc_week_mode';
throughRadio.id = 'dlc_week_mode_through';
throughRadio.value = 'through';
throughRadio.addEventListener('change', onSeasonTotalModeChanged, false);
throughLabel.appendChild(throughRadio);
throughLabel.appendChild(document.createTextNode('Season total through the selected week'));
modeRow.appendChild(throughLabel);
div.appendChild(modeRow);
const progressWrap = document.createElement('div');
progressWrap.id = 'dlc_progress_wrap';
progressWrap.style.marginTop = '6px';
progressWrap.style.maxWidth = '560px';
const progressTopRow = document.createElement('div');
progressTopRow.style.display = 'flex';
progressTopRow.style.alignItems = 'center';
progressTopRow.style.gap = '6px';
progressTopRow.style.marginBottom = '3px';
const cancelBtn = document.createElement('input');
cancelBtn.value = 'Cancel';
cancelBtn.type = 'button';
cancelBtn.id = 'dlc_cancel';
cancelBtn.disabled = true;
cancelBtn.addEventListener('click', cancelCollection, false);
progressTopRow.appendChild(cancelBtn);
const progressText = document.createElement('div');
progressText.id = 'dlc_progress_text';
progressText.style.fontSize = '12px';
progressText.style.opacity = '0.85';
progressText.textContent = 'Idle';
progressText.style.margin = '0';
progressTopRow.appendChild(progressText);
progressWrap.appendChild(progressTopRow);
const progressTrack = document.createElement('div');
progressTrack.style.height = '10px';
progressTrack.style.border = '1px solid #999';
progressTrack.style.background = '#eee';
progressTrack.style.position = 'relative';
const progressBar = document.createElement('div');
progressBar.id = 'dlc_progress_bar';
progressBar.style.height = '100%';
progressBar.style.width = '0%';
progressBar.style.background = '#5a9';
progressBar.style.transition = 'width 120ms linear';
progressTrack.appendChild(progressBar);
progressWrap.appendChild(progressTrack);
div.appendChild(progressWrap);
const content = document.getElementById('content') || document.body;
content.parentNode.insertBefore(div, content.nextSibling);
warnIfSeasonMismatch();
}
async function collect() {
const btn = document.getElementById('dlc_collect');
const cancelBtn = document.getElementById('dlc_cancel');
const weekSelect = document.getElementById('week_input');
let completedSuccessfully = false;
btn.disabled = true;
if (cancelBtn) cancelBtn.disabled = false;
if (weekSelect) weekSelect.disabled = true;
setCollectionModeInputsDisabled(true);
try {
collectionCancelRequested = false;
activeCollectionAbortController = new AbortController();
statTypes = allStatTypes.slice();
players = {};
gamelinks = [];
playerAgentNamesById = {};
playerAgentUserIdsById = {};
rowCountsByType = {};
unknownHeadersByType = {};
setOutputButtonsEnabled(false);
setProgressUi(0, 'Starting collection...');
for (const type of statTypes) rowCountsByType[type] = 0;
logPointsConfigWarnings();
const weekRaw = (document.getElementById('week_input') || {}).value;
const week = parseInt(weekRaw, 10);
if (!Number.isFinite(week) || week < 1 || week > 19) {
alert('Enter a week number from 1 to 19');
return;
}
lastCollectedWeek = week;
const weeksToCollect = getWeeksToCollect(week);
const weekMode = (weeksToCollect.length > 1) ? 'through' : 'single';
log('[DLC v2] Collecting...', { season, week, weeksToCollect, weekMode, statTypes });
// 1) Get unique game links for configured players
const gameLinkSet = new Set();
const totalLogJobs = Math.max(1, playerList.length);
let logJobIndex = 0;
for (let i = 0; i < playerList.length; i++) {
const playerId = playerList[i];
throwIfCollectionCancelled();
logJobIndex += 1;
setProgressUi(
(logJobIndex / totalLogJobs) * 0.5,
`Loading game logs (${logJobIndex}/${totalLogJobs})...`
);
const links = await getGameLinksForPlayer(playerId, weeksToCollect);
throwIfCollectionCancelled();
for (const l of links) gameLinkSet.add(l);
}
gamelinks = [...gameLinkSet];
log('[DLC v2] game links:', gamelinks.length);
setProgressUi(0.5, `Found ${gamelinks.length} game links. Loading boxscores...`);
// 2) Harvest each game
for (let i = 0; i < gamelinks.length; i++) {
throwIfCollectionCancelled();
const g = gamelinks[i];
const frac = gamelinks.length ? 0.5 + (((i + 1) / gamelinks.length) * 0.5) : 1;
setProgressUi(frac, `Harvesting boxscores (${i + 1}/${gamelinks.length})...`);
await harvestGame(g);
}
const playerCount = Object.keys(players).length;
log('[DLC v2] players:', playerCount, players);
log('[DLC v2] players collected count:', playerCount);
for (const type of statTypes) {
log(`[DLC v2] rows gathered [${type}]:`, rowCountsByType[type] || 0);
const unknown = unknownHeadersByType[type];
if (unknown && unknown.length) {
warn(`[DLC v2] unknown headers [${type}]:`, unknown);
}
}
window.DLCv2 = {
players, gamelinks, statTypes, headings, points, sortable,
allStatTypes, rowCountsByType, unknownHeadersByType, playerAgentNamesById, playerAgentUserIdsById, season, weeksToCollect
};
setOutputButtonsEnabled(true);
renderOutputTables();
setProgressUi(1, `Done. ${playerCount} players collected from ${gamelinks.length} games.`);
completedSuccessfully = true;
} catch (e) {
if (isCollectionCancelledError(e)) {
warn('[DLC v2] Collection cancelled');
setProgressUi(0, 'Collection cancelled.');
return;
}
fatal('[DLC v2] Failed:', e);
setProgressUi(0, 'Collection failed. Check console.');
alert('Collection failed. Check browser console for details.');
} finally {
activeCollectionAbortController = null;
collectionCancelRequested = false;
btn.disabled = completedSuccessfully;
if (cancelBtn) cancelBtn.disabled = true;
if (weekSelect) weekSelect.disabled = false;
setCollectionModeInputsDisabled(false);
}
}
function cancelCollection() {
collectionCancelRequested = true;
if (activeCollectionAbortController) activeCollectionAbortController.abort();
setProgressUi(0, 'Cancelling...');
const cancelBtn = document.getElementById('dlc_cancel');
if (cancelBtn) cancelBtn.disabled = true;
}
function throwIfCollectionCancelled() {
if (collectionCancelRequested) {
const err = new Error('DLC_COLLECTION_CANCELLED');
err.name = 'DlcCollectionCancelled';
throw err;
}
}
function isCollectionCancelledError(e) {
return !!e && (
e.name === 'DlcCollectionCancelled' ||
e.message === 'DLC_COLLECTION_CANCELLED' ||
e.name === 'AbortError'
);
}
function onCollectionModeChanged() {
const collectBtn = document.getElementById('dlc_collect');
if (collectBtn) collectBtn.disabled = false;
}
function onSeasonTotalModeChanged(e) {
onCollectionModeChanged();
if (!e || !e.target || !e.target.checked) return;
if (seasonTotalWarningShown) return;
seasonTotalWarningShown = true;
alert("Collecting season total stats makes more requests to GLB servers - especially late in the season. Be responsible and do not spam the 'Collect' button.");
}
function setCollectionModeInputsDisabled(disabled) {
const radios = document.querySelectorAll('input[name="dlc_week_mode"]');
for (const r of radios) r.disabled = !!disabled;
}
function getWeeksToCollect(selectedWeek) {
const through = !!document.getElementById('dlc_week_mode_through')?.checked;
if (!through) return [selectedWeek];
const weeks = [];
for (let w = 1; w <= selectedWeek; w++) weeks.push(w);
return weeks;
}
async function getGameLinksForPlayer(playerId, weeks) {
const url = `${baseUrl}/game/player_game_log.pl?player_id=${playerId}&season=${season}&stat_type=raw`;
const doc = await fetchDoc(url);
const pid = String(playerId);
if (!playerAgentNamesById[pid]) {
try {
const agentInfo = getAgentInfoFromDoc(doc, playerId);
if (agentInfo.name) playerAgentNamesById[pid] = agentInfo.name;
if (agentInfo.userId) playerAgentUserIdsById[pid] = agentInfo.userId;
} catch (e) {
warn('[DLC v2] agent lookup failed from game log for player', playerId, e);
}
}
return parseGameLogLinks(doc, weeks);
}
function getAgentInfoFromDoc(doc, playerId) {
const rows = Array.from(doc.querySelectorAll('tr'));
for (const tr of rows) {
const headCell = tr.querySelector('td.vital_head');
if (!headCell) continue;
const label = (headCell.textContent || '').trim();
if (!/^Agent:\s*$/i.test(label)) continue;
const dataCell = tr.querySelector('td.vital_data');
const agentLink = dataCell ? dataCell.querySelector('a[href*="user_id="]') : null;
const name = agentLink ? (agentLink.textContent || '').trim() : (dataCell ? (dataCell.textContent || '').trim() : '');
const href = agentLink ? agentLink.getAttribute('href') : '';
const m = (href || '').match(/user_id=(\d+)/);
return {
name: name || '',
userId: m ? m[1] : null,
};
}
warn('[DLC v2] Agent row not found on game log page', { playerId });
return { name: '', userId: null };
}
function logPointsConfigWarnings() {
if (pointsConfigWarningsLogged) return;
pointsConfigWarningsLogged = true;
for (const type of allStatTypes) {
const cols = headings[type] || [];
const pts = points[type];
if (!Array.isArray(pts)) {
warn(`[DLC v2] points config missing for [${type}]`);
continue;
}
if (pts.length !== cols.length) {
warn(`[DLC v2] points/headings length mismatch for [${type}]`, {
headings: cols.length,
points: pts.length
});
}
}
}
function setOutputButtonsEnabled(enabled) {
const csvBtn = document.getElementById('dlc_export_csv');
if (csvBtn) csvBtn.disabled = !enabled;
}
function setProgressUi(fraction, message) {
const bar = document.getElementById('dlc_progress_bar');
const text = document.getElementById('dlc_progress_text');
const clamped = Math.max(0, Math.min(1, Number.isFinite(fraction) ? fraction : 0));
if (bar) bar.style.width = `${Math.round(clamped * 100)}%`;
if (text) text.textContent = message || `${Math.round(clamped * 100)}%`;
}
function recordUnknownHeader(type, rawName, normalizedName) {
if (!type) return;
const ignored = ignoredUnknownHeadersByType[type];
if (Array.isArray(ignored) && ignored.includes(normalizedName)) return;
if (!unknownHeadersByType[type]) unknownHeadersByType[type] = [];
const key = `${String(rawName)} => ${String(normalizedName)}`;
if (!unknownHeadersByType[type].includes(key)) {
unknownHeadersByType[type].push(key);
}
}
function parseGameLogLinks(doc, weeks) {
const tables = Array.from(doc.querySelectorAll('table'));
let logTable = null;
const weekList = Array.isArray(weeks) ? weeks : [weeks];
const weekSet = new Set(
weekList
.map((w) => parseInt(w, 10))
.filter((w) => Number.isFinite(w))
);
// Prefer a table with a header containing "Week"
for (const t of tables) {
const firstRow = t.querySelector('tr');
if (!firstRow) continue;
const headers = Array.from(firstRow.querySelectorAll('th,td'))
.map(x => (x.textContent || '').trim());
if (headers.some(h => /week/i.test(h))) {
logTable = t;
break;
}
}
// Fallback: old script used tables[1]
if (!logTable && tables.length >= 2) logTable = tables[1];
if (!logTable) {
warn('[DLC v2] Game log table not found', { weeks: Array.from(weekSet) });
return [];
}
const links = [];
const rows = Array.from(logTable.querySelectorAll('tr')).slice(1);
for (const tr of rows) {
const tds = tr.querySelectorAll('td');
if (!tds || tds.length < 4) continue;
const wk = parseInt((tds[0].textContent || '').trim(), 10);
if (!weekSet.has(wk)) continue;
const a = tds[3].querySelector('a[href*="game_id="]') || tr.querySelector('a[href*="game_id="]');
if (!a) continue;
links.push(absUrl(a.getAttribute('href')));
}
log('[DLC v2] Game log links parsed', {
weeks: Array.from(weekSet),
rowsScanned: rows.length,
linksFound: links.length
});
return links;
}
async function harvestGame(gameUrl) {
const doc = await fetchDoc(gameUrl);
const tables = Array.from(doc.querySelectorAll('table'));
// Look for [category label], then next two tables are teams
for (let i = 0; i < tables.length - 2; i++) {
const rawLabel = readCategoryLabel(tables[i]);
const cat = normalizeCategoryLabel(rawLabel);
if (!cat) continue;
log('[DLC v2] Category label found', { raw: rawLabel, normalized: cat, gameUrl });
if (!statTypes.includes(cat)) {
log('[DLC v2] Category skipped (not enabled for harvest)', { cat, gameUrl });
continue;
}
const team1 = tables[i + 1];
const team2 = tables[i + 2];
if (!looksLikeStatTeamTable(team1) || !looksLikeStatTeamTable(team2)) {
warn('[DLC v2] Category matched but team stat tables not detected', { cat, gameUrl });
continue;
}
const name1 = readTeamName(team1) || 'Team 1';
const name2 = readTeamName(team2) || 'Team 2';
loadPlayersFromTeamTable(team1, cat, name1);
loadPlayersFromTeamTable(team2, cat, name2);
i += 2;
}
}
function readCategoryLabel(table) {
const td = table.querySelector('td');
if (!td) return null;
const text = (td.textContent || '').trim();
if (!text || text.length > 80) return null;
return text;
}
function readTeamName(table) {
const th = table.querySelector('th');
return th ? (th.textContent || '').trim() : null;
}
function normalizeCategoryLabel(label) {
if (!label) return null;
const text = label.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
const aliases = {
'Kick Punt Return': 'Kick/Punt Return',
'Kick/Punt Returns': 'Kick/Punt Return',
'Returns': 'Kick/Punt Return',
'Special Team': 'Special Teams',
};
return aliases[text] || text;
}
function looksLikeStatTeamTable(table) {
if (!table) return false;
const rows = Array.from(table.querySelectorAll('tr'));
if (rows.length < 2) return false;
if (!table.querySelector('a[href*="player_id="]')) return false;
return rows.some(r => r.querySelectorAll('th,td').length > 1);
}
function normalizeStatName(rawName, type, currentStats) {
let statName = (rawName || '').replace(/\u00a0/g, ' ').replace(/\s+/g, '');
const aliases = {
'RecAllowed': 'RecAlw',
'RecAlwd': 'RecAlw',
'RecAlw.': 'RecAlw',
'RevPnkd': 'RevPnkd',
'RevPnk': 'RevPnk',
'FRec': 'FRec',
'FumRec': 'FRec',
'FumRTD': 'FumRTD',
};
if (aliases[statName]) statName = aliases[statName];
// Preserve original category-specific disambiguation for duplicate column labels.
if (type === 'Kick/Punt Return') {
if (statName === 'Yds') statName = (currentStats.KYds == null) ? 'KYds' : 'PYds';
if (statName === 'Avg') statName = (currentStats.KAvg == null) ? 'KAvg' : 'PAvg';
if (statName === 'TD') statName = (currentStats.KTD == null) ? 'KTD' : 'PTD';
}
if (type === 'Defense' && statName === 'Yds') {
statName = (currentStats.SYds == null) ? 'SYds' : 'IYds';
}
return statName;
}
function loadPlayersFromTeamTable(table, type, teamName) {
const rows = Array.from(table.querySelectorAll('tr'));
if (rows.length < 2) return;
// GLB tables usually have a team-name row first, then the actual stat header row.
const headerRowIndex = rows.findIndex((row) => {
const cells = row.querySelectorAll('th,td');
return cells.length > 1 && !row.querySelector('a[href*="player_id="]');
});
if (headerRowIndex < 0) {
warn('[DLC v2] Header row not found in team stat table', { type, teamName });
return;
}
const headerCells = Array.from(rows[headerRowIndex].querySelectorAll('th,td'));
const colNames = headerCells.map(c => (c.textContent || '').trim());
let trackedPlayersFound = 0;
let untrackedPlayersSeen = 0;
for (let i = headerRowIndex + 1; i < rows.length; i++) {
const r = rows[i];
const cells = Array.from(r.querySelectorAll('td'));
if (!cells.length) continue;
const a = cells[0].querySelector('a[href*="player_id="]');
if (!a) continue;
const pid = extractPlayerId(a.getAttribute('href'));
if (!pid) continue;
if (!playerSet.has(pid)) {
untrackedPlayersSeen += 1;
continue;
}
trackedPlayersFound += 1;
const pname = (a.textContent || '').trim();
const ppos = (cells[0].childNodes[0] && cells[0].childNodes[0].textContent)
? cells[0].childNodes[0].textContent.trim()
: '';
if (!players[pid]) {
players[pid] = {
id: pid,
name: pname,
pos: ppos,
tname: (teamName || '').trim(),
stats: {}
};
for (const st of statTypes) players[pid].stats[st] = [];
}
const s = {};
const expectedHeaders = headings[type] || [];
for (let j = 1; j < cells.length && j < colNames.length; j++) {
const rawStatName = colNames[j];
if (!rawStatName) continue;
let statName = normalizeStatName(rawStatName, type, s);
let value = (cells[j].textContent || '').trim().replace(/,/g, '');
if (expectedHeaders.length && !expectedHeaders.includes(statName)) {
recordUnknownHeader(type, rawStatName, statName);
}
// preserve original special-casing
if (statName === 'SackYd') {
const f = parseFloat(value);
value = Number.isFinite(f) ? Math.round(f).toString() : '0';
}
s[statName] = value;
}
players[pid].stats[type].push(s);
rowCountsByType[type] = (rowCountsByType[type] || 0) + 1;
}
if (trackedPlayersFound === 0) {
log('[DLC v2] No tracked players found in matched team stat table', {
type,
teamName,
untrackedPlayersSeen
});
}
}
function extractPlayerId(href) {
const m = (href || '').match(/player_id=(\d+)/);
return m ? m[1] : null;
}
function absUrl(href) {
if (!href) return null;
if (href.startsWith('http://') || href.startsWith('https://')) return href;
if (href.startsWith('/')) return baseUrl + href;
return baseUrl + '/' + href;
}
function num(v) {
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
function fmt(v) {
if (typeof v === 'number') {
return Number.isInteger(v) ? String(v) : v.toFixed(2).replace(/\.00$/, '');
}
return (v == null) ? '' : String(v);
}
function aggregateLines(type, lines) {
if (!Array.isArray(lines) || !lines.length) return null;
const cols = headings[type] || [];
const totals = {};
for (const c of cols) totals[c] = 0;
for (const row of lines) {
if (!row) continue;
for (const key of Object.keys(row)) {
if (totals[key] == null) continue;
totals[key] += num(row[key]);
}
}
// Recompute common derived values after summing raw rows.
if (type === 'Rushing' && totals.Rush > 0 && totals.Avg != null) totals.Avg = totals.Yds / totals.Rush;
if (type === 'Receiving') {
if (totals.Rec > 0 && totals.Avg != null) totals.Avg = totals.Yds / totals.Rec;
}
if (type === 'Kick/Punt Return') {
if (totals.KR > 0 && totals.KAvg != null) totals.KAvg = totals.KYds / totals.KR;
if (totals.PR > 0 && totals.PAvg != null) totals.PAvg = totals.PYds / totals.PR;
}
if (type === 'Punting' && totals.Punts > 0 && totals.Avg != null) totals.Avg = totals.Yds / totals.Punts;
if (type === 'Kicking' && totals.KO > 0 && totals.Avg != null) totals.Avg = totals.Yds / totals.KO;
if (type === 'Passing') {
if (totals.Att > 0) {
if (totals.Pct != null) totals.Pct = parseFloat(((totals.Comp / totals.Att) * 100).toFixed(1));
if (totals['Y/A'] != null) totals['Y/A'] = parseFloat((totals.Yds / totals.Att).toFixed(1));
if (totals.Rating != null) {
const a = Math.max(0, Math.min(2.375, ((totals.Comp / totals.Att) - 0.3) * 5));
const b = Math.max(0, Math.min(2.375, ((totals.Yds / totals.Att) - 3) * 0.25));
const c = Math.max(0, Math.min(2.375, (totals.TD / totals.Att) * 20));
const d = Math.max(0, Math.min(2.375, 2.375 - ((totals.Int / totals.Att) * 25)));
totals.Rating = parseFloat((100 * (a + b + c + d) / 6).toFixed(1));
}
} else {
if (totals.Pct != null) totals.Pct = 0.0;
if (totals['Y/A'] != null) totals['Y/A'] = 0.0;
if (totals.Rating != null) totals.Rating = 0.0;
}
}
return totals;
}
function applyAggregateAdjustments(aggStats, pid) {
const passing = aggStats['Passing'];
const rushing = aggStats['Rushing'];
if (passing && rushing) {
if (rushing.Rush != null) rushing.Rush -= num(passing.Sack);
if (rushing.Yds != null) rushing.Yds -= num(passing.SackYd);
if (num(rushing.Rush) <= 0) {
log('[DLC v2] Rushing aggregate removed after sack adjustment', { pid });
aggStats['Rushing'] = null;
} else if (rushing.Avg != null) {
rushing.Avg = num(rushing.Yds) / num(rushing.Rush);
}
}
const st = aggStats['Special Teams'];
const def = aggStats['Defense'];
if (st && def) {
if (def.Tk != null) def.Tk -= num(st.Tk);
if (def.MsTk != null) def.MsTk -= num(st.MsTk);
if (def.FFum != null) def.FFum -= num(st.FFum);
if (def.FRec != null) def.FRec -= num(st.FRec);
let sum = 0;
for (const s of (headings['Defense'] || [])) {
if (s === 'Ply') continue;
sum += num(def[s]);
}
if (sum === 0) {
log('[DLC v2] Defense aggregate removed after Special Teams subtraction', { pid });
aggStats['Defense'] = null;
}
}
}
function computePoints(type, totals) {
if (!totals) return null;
const pts = points[type];
const cols = headings[type] || [];
if (!Array.isArray(pts) || !pts.length) return null;
let total = 0;
for (let i = 0; i < cols.length; i++) {
total += num(pts[i]) * num(totals[cols[i]]);
}
return total;
}
function buildAggregates() {
const rows = [];
for (const pid of Object.keys(players)) {
const p = players[pid];
const aggStats = {};
for (const type of statTypes) {
aggStats[type] = aggregateLines(type, p.stats[type]);
}
applyAggregateAdjustments(aggStats, pid);
rows.push({
pid,
name: p.name || '',
pos: p.pos || '',
tname: p.tname || '',
agent: playerAgentNamesById[pid] || '',
stats: aggStats,
});
}
rows.sort((a, b) => a.name.localeCompare(b.name));
return rows;
}
function getOrCreateOutputRoot() {
let root = document.getElementById('dlc_output_root');
if (!root) {
root = document.createElement('div');
root.id = 'dlc_output_root';
root.style.marginTop = '10px';
const controls = document.getElementById('dlc_v2_controls');
const anchor = controls || (document.getElementById('content') || document.body);
anchor.parentNode.insertBefore(root, anchor.nextSibling);
}
return root;
}
function renderOutputTables() {
const agg = buildAggregates();
const root = getOrCreateOutputRoot();
root.innerHTML = '';
const summary = document.createElement('div');
summary.className = 'dlc-summary';
summary.textContent = `Week ${lastCollectedWeek ?? '?'} • ${agg.length} players • ${gamelinks.length} games`;
root.appendChild(summary);
root.appendChild(createOutputToolbar());
const nameFilterText = outputNameFilter.trim().toLowerCase();
const agentFilterText = outputAgentFilter.trim().toLowerCase();
const filteredAgg = agg.filter((p) => {
if (nameFilterText && !(p.name || '').toLowerCase().includes(nameFilterText)) return false;
if (agentFilterText && !(p.agent || '').toLowerCase().includes(agentFilterText)) return false;
return true;
});
const tabs = createOutputTabs(filteredAgg);
const tablesPanel = tabs.tablesPanel;
if (tablesPanel) {
const tableTools = document.createElement('div');
tableTools.className = 'dlc-forum-tools';
const collapseBtn = document.createElement('input');
collapseBtn.type = 'button';
collapseBtn.value = 'Collapse All';
collapseBtn.addEventListener('click', () => {
outputCollapsedTypes = new Set(allStatTypes);
renderOutputTables();
});
tableTools.appendChild(collapseBtn);
const expandBtn = document.createElement('input');
expandBtn.type = 'button';
expandBtn.value = 'Expand All';
expandBtn.addEventListener('click', () => {
outputCollapsedTypes = new Set();
renderOutputTables();
});
tableTools.appendChild(expandBtn);
tablesPanel.appendChild(tableTools);
}
for (const type of allStatTypes) {
if (!outputSelectedStatTypes.has(type)) continue;
const sectionRows = filteredAgg
.map(p => ({ ...p, totals: p.stats[type], points: computePoints(type, p.stats[type]) }))
.filter(p => p.totals);
if (!outputShowEmptySections && sectionRows.length === 0) continue;
const section = document.createElement('details');
section.className = 'dlc-type-section';
section.open = !outputCollapsedTypes.has(type);
section.addEventListener('toggle', () => {
if (section.open) outputCollapsedTypes.delete(type);
else outputCollapsedTypes.add(type);
});
const title = document.createElement('summary');
title.className = 'dlc-type-header';
title.textContent = `${type} (${sectionRows.length})`;
section.appendChild(title);
if (sectionRows.length) {
section.appendChild(createStatsTable(type, sectionRows));
} else {
const empty = document.createElement('div');
empty.className = 'dlc-empty-note';
empty.textContent = 'No matching players for current filter.';
section.appendChild(empty);
}
if (tablesPanel) tablesPanel.appendChild(section);
}
root.appendChild(tabs.wrap);
}
function createOutputTabs(filteredAgg) {
const wrap = document.createElement('div');
wrap.className = 'dlc-tabs';
const btns = document.createElement('div');
btns.className = 'dlc-tab-buttons';
const tablesBtn = document.createElement('button');
tablesBtn.type = 'button';
tablesBtn.className = `dlc-tab-btn${outputActiveTab === 'tables' ? ' active' : ''}`;
tablesBtn.textContent = 'Tables';
tablesBtn.addEventListener('click', () => {
if (outputActiveTab !== 'tables') {
outputActiveTab = 'tables';
renderOutputTables();
}
});
btns.appendChild(tablesBtn);
const forumBtn = document.createElement('button');
forumBtn.type = 'button';
forumBtn.className = `dlc-tab-btn${outputActiveTab === 'forum' ? ' active' : ''}`;
forumBtn.textContent = 'Forum';
forumBtn.addEventListener('click', () => {
if (outputActiveTab !== 'forum') {
outputActiveTab = 'forum';
renderOutputTables();
}
});
btns.appendChild(forumBtn);
const rankingsBtn = document.createElement('button');
rankingsBtn.type = 'button';
rankingsBtn.className = `dlc-tab-btn${outputActiveTab === 'rankings' ? ' active' : ''}`;
rankingsBtn.textContent = 'Rankings';
rankingsBtn.addEventListener('click', () => {
if (outputActiveTab !== 'rankings') {
outputActiveTab = 'rankings';
renderOutputTables();
}
});
btns.appendChild(rankingsBtn);
wrap.appendChild(btns);
const panel = document.createElement('div');
panel.className = 'dlc-tab-panel';
wrap.appendChild(panel);
if (outputActiveTab === 'forum') {
panel.appendChild(createForumPanel(filteredAgg));
return { wrap, tablesPanel: null };
}
if (outputActiveTab === 'rankings') {
panel.appendChild(createRankingsPanel(filteredAgg));
return { wrap, tablesPanel: null };
}
return { wrap, tablesPanel: panel };
}
function createOutputToolbar() {
const bar = document.createElement('div');
bar.className = 'dlc-toolbar';
const typesLabel = document.createElement('label');
typesLabel.textContent = 'Stat Types';
typesLabel.style.alignItems = 'flex-start';
const typesWrap = document.createElement('span');
typesWrap.style.display = 'inline-flex';
typesWrap.style.flexWrap = 'wrap';
typesWrap.style.gap = '4px 8px';
typesWrap.style.maxWidth = '700px';
for (const type of allStatTypes) {
const tLabel = document.createElement('label');
tLabel.style.display = 'inline-flex';
tLabel.style.alignItems = 'center';
tLabel.style.gap = '3px';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = outputSelectedStatTypes.has(type);
cb.addEventListener('change', (e) => {
if (e.target.checked) outputSelectedStatTypes.add(type);
else outputSelectedStatTypes.delete(type);
renderOutputTables();
});
tLabel.appendChild(cb);
tLabel.appendChild(document.createTextNode(type));
typesWrap.appendChild(tLabel);
}
typesLabel.appendChild(typesWrap);
bar.appendChild(typesLabel);
const filterLabel = document.createElement('label');
filterLabel.textContent = 'Filter Name';
const filterInput = document.createElement('input');
filterInput.type = 'text';
filterInput.value = outputNameFilter;
filterInput.placeholder = 'Player name...';
filterInput.addEventListener('input', (e) => {
outputNameFilter = e.target.value || '';
renderOutputTables();
});
filterLabel.appendChild(filterInput);
bar.appendChild(filterLabel);
const agentLabel = document.createElement('label');
agentLabel.textContent = 'Filter Agent';
const agentInput = document.createElement('input');
agentInput.type = 'text';
agentInput.value = outputAgentFilter;
agentInput.placeholder = 'Agent name...';
agentInput.addEventListener('input', (e) => {
outputAgentFilter = e.target.value || '';
renderOutputTables();
});
agentLabel.appendChild(agentInput);
bar.appendChild(agentLabel);
const emptyLabel = document.createElement('label');
const emptyCheckbox = document.createElement('input');
emptyCheckbox.type = 'checkbox';
emptyCheckbox.checked = outputShowEmptySections;
emptyCheckbox.addEventListener('change', (e) => {
outputShowEmptySections = !!e.target.checked;
renderOutputTables();
});
emptyLabel.appendChild(emptyCheckbox);
emptyLabel.appendChild(document.createTextNode('Show Empty Sections'));
bar.appendChild(emptyLabel);
const clearBtn = document.createElement('input');
clearBtn.type = 'button';
clearBtn.value = 'Clear Filter';
clearBtn.addEventListener('click', () => {
outputNameFilter = '';
outputAgentFilter = '';
renderOutputTables();
});
bar.appendChild(clearBtn);
return bar;
}
function createForumPanel(filteredAgg) {
const wrap = document.createElement('div');
const tools = document.createElement('div');
tools.className = 'dlc-forum-tools';
const copyBtn = document.createElement('input');
copyBtn.type = 'button';
copyBtn.value = 'Copy Forum Text';
tools.appendChild(copyBtn);
const note = document.createElement('span');
note.textContent = 'One-line rows, compact labels, zero-padded numeric columns, text at end.';
tools.appendChild(note);
wrap.appendChild(tools);
const ta = document.createElement('textarea');
ta.className = 'dlc-forum-text';
ta.readOnly = true;
ta.value = buildForumText(filteredAgg);
wrap.appendChild(ta);
copyBtn.addEventListener('click', async () => {
const text = ta.value || '';
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
ta.focus();
ta.select();
document.execCommand('copy');
}
copyBtn.value = 'Copied';
window.setTimeout(() => { copyBtn.value = 'Copy Forum Text'; }, 1000);
} catch (e) {
copyBtn.value = 'Copy Failed';
warn('[DLC v2] forum copy failed', e);
window.setTimeout(() => { copyBtn.value = 'Copy Forum Text'; }, 1200);
}
});
return wrap;
}
function createRankingsPanel(filteredAgg) {
const wrap = document.createElement('div');
const tools = document.createElement('div');
tools.className = 'dlc-forum-tools';
const copyBtn = document.createElement('input');
copyBtn.type = 'button';
copyBtn.value = 'Copy Rankings';
tools.appendChild(copyBtn);
const note = document.createElement('span');
note.textContent = 'Forum-ready rankings across selected stat types.';
tools.appendChild(note);
wrap.appendChild(tools);
const ta = document.createElement('textarea');
ta.className = 'dlc-forum-text';
ta.readOnly = true;
ta.value = buildRankingsText(filteredAgg);
wrap.appendChild(ta);
copyBtn.addEventListener('click', async () => {
const text = ta.value || '';
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
ta.focus();
ta.select();
document.execCommand('copy');
}
copyBtn.value = 'Copied';
window.setTimeout(() => { copyBtn.value = 'Copy Rankings'; }, 1000);
} catch (e) {
copyBtn.value = 'Copy Failed';
warn('[DLC v2] rankings copy failed', e);
window.setTimeout(() => { copyBtn.value = 'Copy Rankings'; }, 1200);
}
});
return wrap;
}
function buildForumText(filteredAgg) {
const lines = [];
lines.push(`[b]DLC Season ${season} Week ${lastCollectedWeek ?? '?'}[/b]`);
lines.push('');
for (const type of allStatTypes) {
if (!outputSelectedStatTypes.has(type)) continue;
const sectionRows = filteredAgg
.map(p => ({ ...p, totals: p.stats[type], points: computePoints(type, p.stats[type]) }))
.filter(p => p.totals);
if (!outputShowEmptySections && sectionRows.length === 0) continue;
const forumRows = buildForumSectionRows(type, sectionRows);
lines.push(`[b]${type} (${sectionRows.length})[/b]`);
if (forumRows.length) {
lines.push(...forumRows);
} else {
lines.push('[i]No matching players for current filter.[/i]');
}
lines.push('');
}
return lines.join('\n').trimEnd();
}
function buildRankingsText(filteredAgg) {
const ranked = buildRankingsRows(filteredAgg);
const lines = [];
lines.push(`[b]DLC Rankings - Season ${season} Week ${lastCollectedWeek ?? '?'}[/b]`);
const rankWidth = Math.max(1, ...ranked.map((r) => String(r.rank).length));
const totalSpec = getRankingsTotalSpec(ranked);
for (const row of ranked) {
const rankText = String(row.rank).padStart(rankWidth, '0');
const totalText = formatForumValue(row.totalPoints, totalSpec);
lines.push(
`${rankText} | ${totalText} | ${row.agent || ''} | ${row.name || ''} | ${row.team || ''}`
);
}
return lines.join('\n').trimEnd();
}
function buildRankingsRows(filteredAgg) {
const rows = filteredAgg.map((p) => {
let totalPoints = 0;
for (const type of allStatTypes) {
if (!outputSelectedStatTypes.has(type)) continue;
totalPoints += num(computePoints(type, p.stats[type]));
}
return {
pid: p.pid,
name: p.name || '',
team: p.tname || '',
agent: p.agent || '',
totalPoints,
};
});
rows.sort((a, b) => {
if (Math.abs(b.totalPoints - a.totalPoints) > RANK_TIE_EPSILON) return b.totalPoints - a.totalPoints;
return a.name.localeCompare(b.name);
});
let lastScore = null;
let currentRank = 0;
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
if (lastScore == null || Math.abs(r.totalPoints - lastScore) > RANK_TIE_EPSILON) {
currentRank = i + 1;
lastScore = r.totalPoints;
}
r.rank = currentRank;
r.totalPointsText = Number.isFinite(r.totalPoints) ? r.totalPoints.toFixed(2) : '0.00';
}
return rows;
}
function getRankingsTotalSpec(rows) {
let maxIntDigits = 1;
let maxFracDigits = 2;
for (const r of rows) {
const s = Number.isFinite(r.totalPoints) ? r.totalPoints.toFixed(2) : '0.00';
const m = String(s).match(/^[-+]?(\d+)(?:\.(\d+))?$/);
if (!m) continue;
maxIntDigits = Math.max(maxIntDigits, m[1].length);
maxFracDigits = Math.max(maxFracDigits, (m[2] || '').length);
}
return { maxIntDigits, maxFracDigits };
}
function buildForumSectionRows(type, sectionRows) {
if (!sectionRows.length) return [];
const allNumericCols = ['Pts', 'Games'].concat(headings[type] || []);
const rows = sectionRows
.map((p) => ({
name: p.name || '',
pos: p.pos || '',
team: p.tname || '',
nums: buildForumNumericMap(type, p),
}))
.sort((a, b) => {
const ap = num(a.nums.Pts);
const bp = num(b.nums.Pts);
if (bp !== ap) return bp - ap;
return a.name.localeCompare(b.name);
});
const numericCols = allNumericCols.filter((col) => {
return rows.some((r) => num(r.nums[col]) !== 0);
});
const specs = {};
for (const col of numericCols) {
let maxIntDigits = 1;
let maxFracDigits = 0;
for (const r of rows) {
const raw = r.nums[col];
const s = fmt(raw);
const m = String(s).match(/^[-+]?(\d+)(?:\.(\d+))?$/);
if (!m) continue;
maxIntDigits = Math.max(maxIntDigits, m[1].length);
maxFracDigits = Math.max(maxFracDigits, (m[2] || '').length);
}
specs[col] = { maxIntDigits, maxFracDigits };
}
const out = [];
for (const r of rows) {
const parts = numericCols.map((col) => {
const v = formatForumValue(r.nums[col], specs[col]);
return `${v}${forumLabelForCol(col)}`;
});
out.push(`${parts.join(' ')} - ${r.name} | ${r.pos} | ${r.team}`);
}
return out;
}
function buildForumNumericMap(type, p) {
const map = {};
map.Pts = p.points == null ? 0 : p.points;
map.Games = (players[p.pid] && players[p.pid].stats[type]) ? players[p.pid].stats[type].length : 0;
for (const h of (headings[type] || [])) {
map[h] = p.totals[h];
}
return map;
}
function formatForumValue(value, spec) {
const base = fmt(value);
const m = String(base).match(/^([-+]?)(\d+)(?:\.(\d+))?$/);
if (!m || !spec) return String(base);
const sign = m[1] || '';
const intPart = (m[2] || '0').padStart(spec.maxIntDigits, '0');
const fracRaw = m[3] || '';
if (!spec.maxFracDigits) return `${sign}${intPart}`;
const fracPart = fracRaw.padEnd(spec.maxFracDigits, '0');
return `${sign}${intPart}.${fracPart}`;
}
function forumLabelForCol(col) {
if (col === 'Games') return 'g';
if (col === 'Pts') return 'pts';
if (col === 'Rating') return 'rtg';
if (col === 'Yds') return 'yds';
return String(col).toLowerCase();
}
function createStatsTable(type, rowsForType) {
const wrap = document.createElement('div');
wrap.className = 'dlc-table-wrap';
const table = document.createElement('table');
table.className = 'dlc-stats-table';
table.dataset.sortDir = 'desc';
table.dataset.sortCol = '';
const cols = ['Name', 'Pos', 'Team', 'Agent', 'Games'].concat(headings[type] || []).concat(['Pts']);
const thead = document.createElement('thead');
const hr = document.createElement('tr');
cols.forEach((c, idx) => {
const th = document.createElement('th');
th.textContent = c;
th.style.cursor = 'pointer';
th.dataset.colIndex = String(idx);
th.addEventListener('click', () => sortOutputTable(table, idx));
hr.appendChild(th);
});
thead.appendChild(hr);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const p of rowsForType) {
const tr = document.createElement('tr');
const vals = [
p.name,
p.pos,
p.tname,
p.agent,
(players[p.pid] && players[p.pid].stats[type]) ? players[p.pid].stats[type].length : 0,
];
for (const h of (headings[type] || [])) vals.push(fmt(p.totals[h]));
vals.push(p.points == null ? '' : fmt(p.points));
vals.forEach(v => {
const td = document.createElement('td');
td.textContent = String(v);
tr.appendChild(td);
});
tbody.appendChild(tr);
}
table.appendChild(tbody);
const defaultStatCol = sortable[type];
if (Number.isFinite(defaultStatCol)) {
sortOutputTable(table, 5 + defaultStatCol - 1, true);
}
wrap.appendChild(table);
return wrap;
}
function sortOutputTable(table, colIndex, initialDesc) {
const tbody = table.tBodies[0];
if (!tbody) return;
const rows = Array.from(tbody.rows);
let dir = 'asc';
if (initialDesc) {
dir = 'desc';
} else if (table.dataset.sortCol === String(colIndex)) {
dir = table.dataset.sortDir === 'asc' ? 'desc' : 'asc';
}
rows.sort((a, b) => {
const av = (a.cells[colIndex]?.textContent || '').trim();
const bv = (b.cells[colIndex]?.textContent || '').trim();
const an = parseFloat(av);
const bn = parseFloat(bv);
let cmp;
if (Number.isFinite(an) && Number.isFinite(bn) && av !== '' && bv !== '') {
cmp = an - bn;
} else {
cmp = av.localeCompare(bv, undefined, { sensitivity: 'base' });
}
return dir === 'asc' ? cmp : -cmp;
});
for (const r of rows) tbody.appendChild(r);
table.dataset.sortCol = String(colIndex);
table.dataset.sortDir = dir;
}
function csvEscape(v) {
let s = (v == null) ? '' : String(v);
// Spreadsheet formula injection hardening (Excel/Sheets)
if (/^[=+\-@]/.test(s)) s = `'${s}`;
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
function exportCsv() {
const agg = buildAggregates();
const lines = [];
lines.push(`# DLC Export Season ${season} Week ${lastCollectedWeek ?? ''}`);
for (const type of allStatTypes) {
if (!outputSelectedStatTypes.has(type)) continue;
const cols = ['Name','Pos','Team','Agent','Games'].concat(headings[type] || []).concat(['Pts']);
lines.push('');
lines.push(`# ${type}`);
lines.push(cols.map(csvEscape).join(','));
for (const p of agg) {
const totals = p.stats[type];
if (!totals) continue;
const row = [
p.name,
p.pos,
p.tname,
p.agent,
(players[p.pid] && players[p.pid].stats[type]) ? players[p.pid].stats[type].length : 0,
];
for (const h of (headings[type] || [])) row.push(fmt(totals[h]));
row.push(fmt(computePoints(type, totals)));
lines.push(row.map(csvEscape).join(','));
}
}
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `dlc_s${season}_w${lastCollectedWeek ?? 'x'}.csv`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 0);
}
async function fetchDoc(url) {
const opts = { credentials: 'same-origin' };
if (activeCollectionAbortController) opts.signal = activeCollectionAbortController.signal;
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
const html = await res.text();
return new DOMParser().parseFromString(html, 'text/html');
}
function detectSeasonInfoFromPage() {
const seasonEl = document.getElementById('season');
const text = (seasonEl?.textContent || '').trim();
const seasonMatch = text.match(/Season\s+(\d+)/i);
const dayMatch = text.match(/Day\s+(-?\d+)/i);
return {
season: seasonMatch ? parseInt(seasonMatch[1], 10) : null,
day: dayMatch ? parseInt(dayMatch[1], 10) : null,
};
}
function latestProcessedWeekFromDay(day) {
if (!Number.isFinite(day) || day > 38) return 19;
if (day < 1) return 1;
return Math.max(1, Math.min(19, Math.ceil(day / 2)));
}
function populateWeekOptions(selectEl, seasonInfo) {
if (!selectEl) return;
const info = seasonInfo || detectSeasonInfoFromPage();
const maxWeek = latestProcessedWeekFromDay(info.day);
selectEl.innerHTML = '';
for (let w = 1; w <= maxWeek; w++) {
const opt = document.createElement('option');
opt.value = String(w);
opt.textContent = getWeekLabel(w);
selectEl.appendChild(opt);
}
let defaultWeek = maxWeek;
if (Number.isFinite(info.day) && info.day > 1 && (info.day % 2 !== 0)) {
defaultWeek = Math.max(1, maxWeek - 1);
}
selectEl.value = String(defaultWeek);
}
function getWeekLabel(week) {
if (week >= 1 && week <= 16) return `Regular Season - Week ${week}`;
if (week === 17) return 'Playoffs - Quarterfinals';
if (week === 18) return 'Playoffs - Semifinals';
if (week === 19) return 'Playoffs - Finals';
return String(week);
}
function warnIfSeasonMismatch() {
if (seasonMismatchWarned) return;
seasonMismatchWarned = true;
const pageSeason = detectSeasonInfoFromPage().season;
if (!Number.isFinite(pageSeason)) return;
if (pageSeason === season) return;
alert(
`DLC season config mismatch.\n\n` +
`Home page shows Season ${pageSeason}, but this script is set to Season ${season}.\n\n` +
`Before running, update these values at the top of the script:\n` +
`- season\n` +
`- playerList`
);
}
})();