diff --git a/endpoint_handlers/licenseinfo.js b/endpoint_handlers/licenseinfo.js new file mode 100644 index 0000000..8c34be2 --- /dev/null +++ b/endpoint_handlers/licenseinfo.js @@ -0,0 +1,26 @@ +const LICENCEINFO_ENDPOINT = 'https://fp.trafikverket.se/Boka/licence-information' + +async function getLicenceInfo(req, res) { + // Check for the required parameters + let response = await fetch(LICENCEINFO_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36' + }, + // body: JSON.stringify({ + // licenceId: licenceId, + // }), + }) + if (!response.ok) { + res.status(500).json({ message: 'Something went wrong' }); + return; + } + + let json = await response.json() + // console.log(json) + + res.json(json) +} + +module.exports = getLicenceInfo; \ No newline at end of file diff --git a/endpoint_handlers/metadata.js b/endpoint_handlers/metadata.js new file mode 100644 index 0000000..ceffebe --- /dev/null +++ b/endpoint_handlers/metadata.js @@ -0,0 +1,52 @@ +const METADATA_ENDPOINT = 'https://fp.trafikverket.se/boka/search-information' + +const METADATA_TEMPLATE = { + "bookingSession": { + "socialSecurityNumber": "", + "licenceId": "", + "bookingModeId": 0, + "ignoreDebt": false, + "ignoreBookingHindrance": false, + "examinationTypeId": 0, + "excludeExaminationCategories": [], + "rescheduleTypeId": 0, + "paymentIsActive": false, + "paymentReference": null, + "paymentUrl": null, + "searchedMonths": 0 + } +} + +async function getMetadataEndpoint(req, res) { + // Check for the required parameters + if (!req.body || !req.body.licenceId || !req.body.ssn) { + res.status(400).json({ message: 'Missing required parameters' }); + return; + } + let ssn = req.body.ssn; + let licenceId = req.body.licenceId; + + let body = METADATA_TEMPLATE + body.bookingSession.socialSecurityNumber = ssn + body.bookingSession.licenceId = licenceId + + let response = await fetch(METADATA_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36' + }, + body: JSON.stringify(body), + }) + if (!response.ok) { + res.status(500).json({ message: 'Something went wrong' }); + return; + } + + let json = await response.json() + // console.log(json) + + res.json(json) +} + +module.exports = getMetadataEndpoint; \ No newline at end of file diff --git a/endpoint_handlers/occasions.js b/endpoint_handlers/occasions.js new file mode 100644 index 0000000..db46b2e --- /dev/null +++ b/endpoint_handlers/occasions.js @@ -0,0 +1,77 @@ +const OCCASION_ENDPOINT = 'https://fp.trafikverket.se/boka/occasion-bundles' + +const OCCASION_TEMPLATE = { + "bookingSession": { + "socialSecurityNumber": "", + "licenceId": 4, + "bookingModeId": 0, + "ignoreDebt": false, + "ignoreBookingHindrance": false, + "examinationTypeId": 0, + "excludeExaminationCategories": [], + "rescheduleTypeId": 0, + "paymentIsActive": true, + "paymentReference": null, + "paymentUrl": null, + "searchedMonths": 0 + }, + "occasionBundleQuery": { + "startDate": "1970-01-01T00:00:00.000Z", + "searchedMonths": 0, + "locationId": 1000060, + "nearbyLocationIds": [], + "languageId": 13, + "vehicleTypeId": 1, + "tachographTypeId": 1, + "occasionChoiceId": 1, + "examinationTypeId": 50 + } +} +// "nearbyLocationIds": [1000121, 1000302, 1000047, 1000009, 1000096, 1000122, 1000066, 1000139, 1000077, 1000138, 1000317, 1000123, 1000069, 1000334, 1000106, 1000103, 1000012, 1000078, 1000318, 1000038, 1000005, 1000087, 1000130, 1000144], + +async function getOccasionEndpoint(req, res) { + // Check for the required parameters + if (!req.body || !req.body.licenceId || !req.body.ssn || !req.body.locationId || !req.body.examType) { + res.status(400).json({ message: 'Missing required parameters' }); + return; + } + let ssn = req.body.ssn; + let licenceId = req.body.licenceId; + let locationId = req.body.locationId; + let nearbyIds = req.body.nearbyIds; + let examType = req.body.examType; + + if (!locationId.length > 0) { + res.status(400).json({ message: 'Missing required parameters' }); + return; + } + + let body = OCCASION_TEMPLATE + body.bookingSession.socialSecurityNumber = ssn + body.bookingSession.licenceId = licenceId + body.occasionBundleQuery.locationId = locationId + body.occasionBundleQuery.nearbyLocationIds = nearbyIds + body.occasionBundleQuery.examinationTypeId = examType + + console.log(body) + + let response = await fetch(OCCASION_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36' + }, + body: JSON.stringify(body), + }) + if (!response.ok) { + res.status(500).json({ message: 'Something went wrong' }); + return; + } + + let json = await response.json() + // console.log(json) + + res.json(json) +} + +module.exports = getOccasionEndpoint; \ No newline at end of file diff --git a/endpoint_handlers/suggested.js b/endpoint_handlers/suggested.js new file mode 100644 index 0000000..8fd36ed --- /dev/null +++ b/endpoint_handlers/suggested.js @@ -0,0 +1,34 @@ +const SUGGESTED_URL = 'https://fp.trafikverket.se/boka/get-suggested-reservations-by-licence-and-ssn' + +async function getSuggestedEndpoint(req, res) { + // Check for the required parameters + if (!req.body || !req.body.licenceId || !req.body.ssn) { + res.status(400).json({ message: 'Missing required parameters' }); + return; + } + let ssn = req.body.ssn; + let licenceId = req.body.licenceId; + + let response = await fetch(SUGGESTED_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36' + }, + body: JSON.stringify({ + ssn: ssn, + licenceId: licenceId, + }), + }) + if (!response.ok) { + res.status(500).json({ message: 'Something went wrong' }); + return; + } + + let json = await response.json() + console.log(json) + + res.json(json) +} + +module.exports = getSuggestedEndpoint; \ No newline at end of file diff --git a/html/index.html b/html/index.html index 3fcbdcc..940049e 100644 --- a/html/index.html +++ b/html/index.html @@ -5,59 +5,39 @@ + - + TFSearch +
- - + - + - +
- +
+ - - - - - - - - - - - - - - - - - -
Kort Pris DatumTid Länk
1234
1234
1234
diff --git a/index.js b/index.js index a20db78..83fd3a9 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,10 @@ const express = require('express'); +const getSuggestedEndpoint = require('./endpoint_handlers/suggested.js'); +const getMetadataEndpoint = require('./endpoint_handlers/metadata.js'); +const getOccasionsEndpoint = require('./endpoint_handlers/occasions.js'); +const getLicenceInfo = require('./endpoint_handlers/licenseinfo.js'); + // Read the port from the environment if possible const port = process.env.PORT || 3000; @@ -8,13 +13,25 @@ const server = express(); // Set up the static directory to server cssa and other assets server.use('/static', express.static('static')); +server.use(express.json()); +server.use( + express.urlencoded({ + extended: true, + }) +); + // Root path serves index server.get('/', (req, res) => { res.sendFile(__dirname + '/html/index.html'); }); +// Set up API endpoints +server.post('/suggested', getSuggestedEndpoint) +server.post('/metadata', getMetadataEndpoint) +server.post('/occasions', getOccasionsEndpoint) +server.post('/licenseinfo', getLicenceInfo) + // Listen and provide feedback server.listen(port, () => { console.log(`Server running on port ${port}`); -} -); \ No newline at end of file +}); \ No newline at end of file diff --git a/static/script.js b/static/script.js index 6a43b29..ebe92a8 100644 --- a/static/script.js +++ b/static/script.js @@ -1 +1,318 @@ -console.log("Javascript is executing properly..."); \ No newline at end of file +console.log("Javascript is executing properly..."); + +const QueryParams = { + SSN: "", + LicenseId: "", + LocationIds: [], + examType: "", // Unused for now +} + +const AppState = { + LocationsFetched: "", + SearchMessage: "", + SearchResults: [], + LocationIndex: {}, +} + +// Clear the state and the UI +async function clearState() { + AppState.SearchResults = []; + // AppState.LocationIndex = {}; + // AppState.SearchMessage = ""; + // searchResults.innerHTML = ""; + resultsTable.innerHTML = ""; + // locationSelector.innerHTML = ""; + // locationSelector.disabled = true; +} + +async function readState() { + clearState(); + console.log("Checking state...") + if (ssnInput.value.length == 12 && !isNaN(ssnInput.value)) { + console.log("SSN is valid...") + QueryParams.SSN = ssnInput.value; + // if (!AppState.LocationsFetched) + await populateLicenses(); + QueryParams.LicenseId = licenseSelector.value; + await populateLocations(await queryMetadata(QueryParams.SSN, QueryParams.LicenseId)); + licenseSelector.disabled = false; + locationSelector.disabled = false; + } else { + // SSN was changed, but is not valid + QueryParams.SSN = ""; + licenseSelector.disabled = true; + locationSelector.disabled = true; + return + } + + if (licenseSelector.value.length > 0 && licenseSelector.value != AppState.LocationsFetched) { + AppState.LocationsFetched = licenseSelector.value; + QueryParams.LicenseId = licenseSelector.value; + + QueryParams.examType = (await querySuggested(QueryParams.SSN, QueryParams.LicenseId)).data[0].examinationTypeId; + + // await populateLocations(await queryMetadata(QueryParams.SSN, QueryParams.LicenseId)); + return + } + + if (locationSelector.value.length > 0 && AppState.LocationsFetched) { + // console.log(locationSelector.value) + if (locationSelector.value == "all") { + QueryParams.LocationIds = Object.keys(AppState.LocationIndex) + } else { + QueryParams.LocationIds = [locationSelector.value]; + } + // console.log(QueryParams) + } + return +} + +function readyForSearch() { + console.log("State is ready for search...") + return QueryParams.SSN.length == 12 && QueryParams.LicenseId.length > 0 && QueryParams.LocationIds.length > 0; +} + +const licenseSelector = document.getElementById("license-selector"); +const ssnInput = document.getElementById("ssn-input"); +const locationSelector = document.getElementById("location-selector"); +const searchButton = document.getElementById("searchButton"); +const resultsTable = document.getElementById("results-table"); +// const resultsContainer = document.getElementById("results-container"); + +async function executeSearch() { + console.log("Executing search...") + await readState() + console.log(QueryParams) + if (!readyForSearch()) { + console.log("Not ready for search...") + return; + } + await querySeveralOccasions(); + // console.log(AppState.SearchResults) + await displaySearchResults(); +} + +async function displaySearchResults() { + // console.log(AppState.SearchResults) + console.log("Displaying search results...") + AppState.SearchResults.sort((a, b) => { + if (a.date < b.date) return -1; + if (a.date > b.date) return 1; + return 0; + }) + console.log(AppState.SearchResults) + resultsTable.innerHTML = " Kort Ort Pris Datum Tid Länk " + for (let occ of AppState.SearchResults) { + let tr = document.createElement("tr"); + tr.className = "table-row"; + let td_license = document.createElement("td"); + let td_location = document.createElement("td"); + let td_price = document.createElement("td"); + let td_date = document.createElement("td"); + let td_time = document.createElement("td"); + let td_link = document.createElement("td"); + + td_license.innerText = occ.name; + td_location.innerText = occ.location; + td_price.innerText = occ.cost; + td_date.innerText = occ.date; + td_time.innerText = occ.time; + td_link.innerText = "-" + + tr.appendChild(td_license); + tr.appendChild(td_location); + tr.appendChild(td_price); + tr.appendChild(td_date); + tr.appendChild(td_time); + tr.appendChild(td_link); + + resultsTable.appendChild(tr); + } +} + +async function querySuggested(ssn, licenseId) { + let msg = await (await fetch('/suggested', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + licenceId: licenseId, + ssn: ssn, + }), + })).json() + return msg +} + +// The metadata is specific to the license type +async function queryMetadata(ssn, licenseId) { + console.log("Getting metadata...") + let msg = await (await fetch('/metadata', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + licenceId: licenseId, + ssn: ssn, + }), + })).json() + return msg +} + +async function queryOccasions() { + console.log("Querying occasions...") + let occasions = [] + // console.log(QueryParams) + for (const locationId of QueryParams.LocationIds) { + let msg = await (await fetch('/occasions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + licenceId: QueryParams.LicenseId, + ssn: QueryParams.SSN, + locationId: locationId, + examType: QueryParams.examType, + }), + })).json() + if (!msg || !msg.data || !msg.data.bundles) continue; + for (let occasion of msg.data.bundles) { + occ = occasion.occasions[0]; + + occasions.push({ + name: occ.name, + location: occ.locationName, + cost: occasion.cost, + date: occ.date, + time: occ.time, + }); + } + + console.log(occasions) + // return msg + // break + } + AppState.locationResults = occasions; +} + +async function querySeveralOccasions() { + console.log("Querying several occasions...") + i = 0 + while (i < QueryParams.LocationIds.length) { + let current_array = QueryParams.LocationIds.slice(i, i + 4) + console.log(current_array) + let request = await (await fetch('/occasions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + licenceId: QueryParams.LicenseId, + ssn: QueryParams.SSN, + locationId: current_array[0], // The first + nearbyIds: current_array.slice(1), // The rest + examType: QueryParams.examType, + }), + })).json() + if (!request || !request.data || !request.data.bundles) continue; + let bundles = request.data.bundles + for (let occasion of bundles) { + let occ = occasion.occasions[0]; + + AppState.SearchResults.push({ + name: occ.name, + location: occ.locationName, + cost: occasion.cost, + date: occ.date, + time: occ.time, + }); + } + + i += 4 + } +} + +async function queryLicenseinfo() { + console.log("Querying license info...") + let msg = await (await fetch('/licenseinfo', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + })).json() + return msg + // console.log(msg) +} + +// let selector = document.getElementById('license-selector') +async function populateLicenses() { + console.log("Populating licenses...") + let licenseinfo = await queryLicenseinfo() + + // slice(0, 2) to only show the first two license types (car and motorcycle) + for (const license_cat of licenseinfo.data.licenceCategories.slice(0, 2)) { + for (const license of license_cat.licences) { + const new_option = document.createElement('option') + new_option.value = license.id + new_option.innerText = `${license.name} ${license.description}` + licenseSelector.appendChild(new_option) + } + } +} + +// Executed each time the license type is changed +async function licenceChanged(field) { + QueryParams.LicenseId = field.value + readState() + + // let metadata_response = await querySuggested(QueryParams.SSN, QueryParams.LicenseId) + // if (!metadata_response.data[0]) { + // QueryParams.SearchMessage = "This license type is not available for this person." + // return + // } + // QueryParams.examType = metadata_response.data[0].examinationTypeId + + // console.log(QueryParams) + // let metadata = await queryMetadata(QueryParams.SSN, QueryParams.LicenseId) + // populateLocations(metadata) +} + +async function populateLocations(response_json) { + if (!response_json.data) { + console.log("No locations found, likely throttled") + return; + } + for (const location of response_json.data.locations) { + // Populate the location index so that we can retrieve other data later + AppState.LocationIndex[location.location.id] = location + + let name = location.location.name + let id = location.location.id + + const new_option = document.createElement('option') + new_option.value = id + new_option.innerText = `${name}` + locationSelector.appendChild(new_option) + locationSelector.disabled = false + searchButton.disabled = false + // console.log(name, id) + // const new_option = document.createElement('option') + // new_option.value = location.id + // new_option.innerText = location.name + // locationSelector.appendChild(new_option) + } + // console.log(AppState.LocationIndex) +} + +async function locationChanged(field) { + readState() +} + +let ssn_input_element = document.getElementById('ssn-input') +async function ssnChanged(field) { + readState() +} + +readState() \ No newline at end of file diff --git a/static/style.css b/static/style.css index 25c7ef5..360e992 100644 --- a/static/style.css +++ b/static/style.css @@ -29,9 +29,11 @@ } #search-container { + max-width: 800px; background-color: #fff; display: flex; flex-direction: row; + width: 100%; } #search-container input { @@ -55,6 +57,12 @@ font-size: 16px; } +#search-container select { + width: 100%; + padding: 0 20px; + font-size: 16px; +} + #results-container {} table { @@ -66,7 +74,7 @@ th, td { border-bottom: 1px solid #ddd; text-align: center; - padding: 16px; + padding: 4px 16px; } tr:nth-child(even) {