Skip to content

Instantly share code, notes, and snippets.

@fireattack
Created July 4, 2025 16:24
Show Gist options
  • Save fireattack/c916e5626df50983883c77d8a8d93c32 to your computer and use it in GitHub Desktop.
Save fireattack/c916e5626df50983883c77d8a8d93c32 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name Asobiticket2 ticket summary
// @namespace https://x.com/ikenaikoto
// @version 3.4
// @description Add a table to the Asobiticket2 page that summarizes ticket information.
// @author fireattack
// @match https://asobiticket2.asobistore.jp/*
// @grant GM_addStyle
// ==/UserScript==
(function() {
'use strict';
const styles = `
#statusFilters {
margin: 10px 0;
}
#statusFilters label {
margin-right: 10px;
}
#resizableTableWrapper {
position: relative;
padding-bottom: 12px; /* Space for the resize handle */
}
#dataTable {
background-color: white;
border: 1px solid black;
padding: 5px;
font-size: 14px;
max-height: 400px; /* Initial value, JS can override */
overflow-y: scroll;
}
#dataTable table {
width: 100%;
border-collapse: collapse;
}
#dataTable th, #dataTable td {
border: 1px solid #ddd; /* Cell borders */
padding: 4px; /* Cell padding */
text-align: left; /* Default text alignment */
}
#dataTable thead th {
cursor: pointer;
background-color: #f0f0f0; /* Light grey background for headers */
}
#resizeHandle {
position: absolute;
bottom: 1px;
left: 0px;
width: 100%;
height: 10px;
cursor: ns-resize;
background-color: #B0B0B0;
opacity: 0.7;
z-index: 100;
}
`;
GM_addStyle(styles);
const targetURL = 'https://asobi-ticket.api.app.t-riple.com/api/v1/';
let cachedData = null;
const statusConfig = {
"won_waiting_payment": { color: "#ffff00", text: "当選・入金待ち", default: true , order: 0},
"before_lottery": { color: "#ffffcc", text: "抽選待ち", default: true, order: 1},
"won_paid": { color: "#ccffcc", text: "当選・入金完了", default: true, order: 2},
"won_not_paid": { color: "#ffcccc", text: "入金期限切れ", default: false, order: 3},
"canceled": { color: "#666666", text: "キャンセル", default: false, order: 4},
"lost": { color: "#cccccc", text: "落選", default: false, order: 5},
};
const activeStatuses = new Set(Object.keys(statusConfig).filter(key => statusConfig[key].default));
const createIdMap = (included) => {
const idMap = {};
included.forEach(item => {
idMap[item.id] = item;
});
return idMap;
};
const createStatusFilters = () => {
const filterContainer = document.createElement('div');
filterContainer.id = 'statusFilters';
Object.entries(statusConfig).forEach(([statusKey, { text, default: isChecked }]) => {
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = isChecked;
checkbox.value = statusKey;
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
activeStatuses.add(statusKey);
} else {
activeStatuses.delete(statusKey);
}
updateTable();
});
label.appendChild(checkbox);
label.appendChild(document.createTextNode(` ${text}`));
filterContainer.appendChild(label);
});
const resizableWrapper = document.getElementById('resizableTableWrapper');
if (resizableWrapper) {
resizableWrapper.insertAdjacentElement('beforebegin', filterContainer);
} else {
console.debug('WARN: Could not find resizableTableWrapper to insert filters.');
}
};
const createTable = () => {
// Create the main wrapper for the table and its resize handle
const resizableTableWrapper = document.createElement('div');
resizableTableWrapper.id = 'resizableTableWrapper';
// Create the table container (the scrollable part)
const tableContainer = document.createElement('div');
tableContainer.id = 'dataTable';
// Create table structure
const table = document.createElement('table');
table.innerHTML = `
<thead id="tableHeader"></thead>
<tbody id="dataBody">
<tr><td colspan="6">Waiting for data...</td></tr>
</tbody>
`;
tableContainer.appendChild(table);
resizableTableWrapper.appendChild(tableContainer);
// Create resize handle
const resizeHandle = document.createElement('div');
resizeHandle.id = 'resizeHandle';
resizableTableWrapper.appendChild(resizeHandle);
const headerElement = document.querySelector('asb-site-header');
if (headerElement) {
headerElement.insertAdjacentElement('afterend', resizableTableWrapper);
createStatusFilters();
if (cachedData) {
updateTable();
}
// Resizing logic
let isResizing = false;
let lastDownY = 0;
let initialMaxHeight = 0;
resizeHandle.addEventListener('mousedown', (e) => {
e.preventDefault();
isResizing = true;
lastDownY = e.clientY;
initialMaxHeight = parseInt(tableContainer.style.maxHeight, 10) || 400;
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
function handleMouseMove(e) {
if (!isResizing) return;
const deltaY = e.clientY - lastDownY;
let newMaxHeight = initialMaxHeight + deltaY;
newMaxHeight = Math.max(50, newMaxHeight); // Minimum height 50px
tableContainer.style.maxHeight = `${newMaxHeight}px`;
}
function handleMouseUp() {
if (!isResizing) return;
isResizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
} else {
console.debug('WARN: Could not find the asb-site-header element to insert the table.');
}
};
const makeTableSortable = () => {
const table = document.querySelector('#dataTable table');
const headers = table.querySelectorAll('thead th');
const tbody = table.querySelector('tbody');
headers.forEach((header, columnIndex) => {
header.addEventListener('click', () => {
const rows = Array.from(tbody.rows);
const isAscending = header.dataset.sortOrder !== 'asc';
header.dataset.sortOrder = isAscending ? 'asc' : 'desc';
rows.sort((a, b) => {
const cellA = a.cells[columnIndex].textContent.trim();
const cellB = b.cells[columnIndex].textContent.trim();
return isAscending
? cellA.localeCompare(cellB, 'ja', { numeric: true })
: cellB.localeCompare(cellA, 'ja', { numeric: true });
});
rows.forEach(row => tbody.appendChild(row));
});
});
};
const formatDatetime = (datetime) => {
if (datetime === 'N/A') return 'N/A';
const date = new Date(datetime);
const options = { year: '2-digit', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' };
return date.toLocaleString('ja-JP', options);
};
const reshapeData = (entries, included) => {
const idMap = createIdMap(included);
const headers = ["Status", "Amount", "Event Name", "Seat Type", "お申込み日時 (local)", "抽選結果発表 (local)"];
// Deduplicate entries by entry.id
const uniqueEntriesMap = new Map();
entries.forEach(entry => {
uniqueEntriesMap.set(entry.id, entry);
});
const uniqueEntries = Array.from(uniqueEntriesMap.values());
const rows = uniqueEntries.map(entry => {
let statusKey = entry.attributes.status;
if (statusKey === 'won_not_paid') {
const paymentDeadline = new Date(entry.attributes.payment_deadline_at);
if (paymentDeadline > new Date()) {
statusKey = 'won_waiting_payment';
}
}
if (!activeStatuses.has(statusKey)) return null;
const { text: status, color } = statusConfig[statusKey];
const amount = (entry.attributes.amount || entry.attributes.amount_estimated || NaN);
const applicationDate = formatDatetime(entry.attributes.created_at || 'N/A');
const receptionData = idMap[entry.relationships.reception.data.id];
const tourName = receptionData?.relationships?.tour?.data?.id ? idMap[receptionData.relationships.tour.data.id]?.attributes?.name + '<br />' : '';
const lotteryResultDate = formatDatetime(receptionData?.attributes?.result_announcement_scheduled_at || 'N/A');
const choices = entry.relationships.choices.data.map(choice => {
const choiceData = idMap[choice.id];
const actName = choiceData?.relationships?.act?.data?.id ? idMap[choiceData.relationships.act.data.id]?.attributes?.name : 'N/A';
const seatType = choiceData?.relationships?.seat?.data?.id ? idMap[choiceData.relationships.seat.data.id]?.attributes?.name : 'N/A';
const seatQuantity = choiceData?.relationships?.seat_plan_choices?.data?.[0]?.id ?
idMap[choiceData.relationships.seat_plan_choices.data[0].id]?.attributes?.quantity : null;
const seatPrice = choiceData?.attributes?.amount || null; // the price is the total price for the seat type, not per seat
return { actName, seatType, seatQuantity, seatPrice };
});
const eventName = tourName + choices[0].actName + (receptionData?.attributes?.name ? `【${receptionData.attributes.name}】` : '');
const mergedSeatTypes = choices.map(choice => {
let label = choice.seatType;
if (choices.length > 1) {
console.debug(`DEBUG: Processing choice: ${JSON.stringify(choice)} vs ${amount}`);
}
if (
choices.length > 1 &&
statusKey !== 'before_lottery' &&
statusKey !== 'lost' &&
typeof choice.seatPrice === 'number' &&
choice.seatPrice === amount
) {
label = `<b style='color:red'>${choice.seatType}</b>`;
}
return label;
}).join('→') + (choices[0].seatQuantity ? ` (${choices[0].seatQuantity}枚)` : '');
const amount_str = amount.toLocaleString() + ((statusKey === 'before_lottery' || statusKey === 'lost') ? ' (max)' : '');
return { statusKey, row: [status, amount_str, eventName, mergedSeatTypes, applicationDate, lotteryResultDate], color };
}).filter(Boolean);
return { headers, rows };
};
const updateTable = () => {
const {entries, included} = cachedData;
console.debug(`INFO: Updating table with ${entries.length} entries and ${included.length} included items.`);
const dataBody = document.getElementById('dataBody');
const tableHeader = document.getElementById('tableHeader');
if (!dataBody || !tableHeader) return;
dataBody.innerHTML = '';
tableHeader.innerHTML = '';
const { headers, rows } = reshapeData(entries, included);
// Add headers
const headerRow = document.createElement('tr');
headers.forEach(header => {
const th = document.createElement('th');
th.textContent = header;
headerRow.appendChild(th);
});
tableHeader.appendChild(headerRow);
// sort rows by status order by default
rows.sort((a, b) => statusConfig[a.statusKey].order - statusConfig[b.statusKey].order);
// Add rows
rows.forEach(({ statusKey, row: rowData, color }) => {
const row = document.createElement('tr');
row.style.backgroundColor = color;
rowData.forEach(cellData => {
const td = document.createElement('td');
td.innerHTML = cellData;
row.appendChild(td);
});
dataBody.appendChild(row);
});
makeTableSortable();
};
const logRequest = (url, content, type) => {
try {
const parsedData = JSON.parse(content);
console.debug(`[${type}] Captured a request to ${url}:`, parsedData);
if (url.includes('payment_required')) return;
if (parsedData.data?.length > 0) {
if (cachedData) {
cachedData.entries.push(...parsedData.data);
cachedData.included.push(...parsedData.included);
} else {
cachedData = { entries: parsedData.data, included: parsedData.included };
}
updateTable();
}
} catch (e) {
console.debug('ERROR: Error processing response:', e);
}
};
const tryCreateTable = () => {
if (document.getElementById('dataTable')) {
return;
}
// Only create the table if we are on the correct page.
if (!window.location.href.startsWith('https://asobiticket2.asobistore.jp/mypage/entries')) {
return;
}
// createTable() internally checks for 'asb-site-header'
createTable();
};
tryCreateTable();
const tableCreationObserver = new MutationObserver(() => {
tryCreateTable();
});
tableCreationObserver.observe(document.documentElement, {
childList: true,
subtree: true,
});
// Intercept XHR requests
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url, ...args) {
this.addEventListener('load', function() {
if (url.startsWith(targetURL)) {
logRequest(url, this.responseText, 'xhr');
}
});
return originalOpen.call(this, method, url, ...args);
};
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment