Created
July 4, 2025 16:24
-
-
Save fireattack/c916e5626df50983883c77d8a8d93c32 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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