anstosa / ferry.fyi

A better tracker for the Washington State Ferry System
https://ferry.fyi
GNU General Public License v3.0
11 stars 3 forks source link

Add saved passes #37

Closed anstosa closed 2 years ago

anstosa commented 3 years ago
anstosa commented 3 years ago

Allow file upload and live camera scan

anstosa commented 3 years ago
// based on code donated by @jordansoltman, the developer for Ferry Friend on iOS
import axios from "axios";
import jsdom from "jsdom";
import AbstractSourceTicketProvider from "../../abstract/providers/abstract_source_ticket_provider";
import { Result } from "../../lib/result";
import { WSFTicketData } from "../../types/interfaces";
import { validateObjectHasOnlyProperties } from "../../lib/util/validate";

const { JSDOM } = jsdom;
const TICKET_HTML_KEYS = [
    "Plu",
    "ItemName",
    "Description",
    "Price",
    "Status",
    "ExpirationDate",
    "TotalRemainingUses",
    "VisualId"
];
// FIXME this should be derrived from the interface. Not hard coded here.
const TICKET_RESPONSE_KEYS = [
    "visualId",
    "plu",
    "itemName",
    "description",
    "price",
    "status",
    "expirationDate",
    "totalUsesRemaining"
];

export default class SourceTicketProvider extends AbstractSourceTicketProvider {
    async getTicketStatus(ticketNumber: string): Promise<Result<WSFTicketData, string>> {
        const cookie = await this.getWSFTicketCookie();
        const values = await this.scrapeTicket(cookie, ticketNumber);
        return values;
    }

    private getTicketValues(element: Element): string[] {
        const spans = [...element.querySelectorAll("span")];

        const dataTexts = spans.filter((span) => {
            return (
                span.getAttributeNames().includes("data-text") &&
                TICKET_HTML_KEYS.includes(span.getAttribute("data-text"))
            );
        });

        return dataTexts.map((text) => text.innerHTML);
    }

    private async scrapeTicket(cookie: string, number: string): Promise<Result<WSFTicketData>> {
        const axiosOpts = {
            headers: {
                Cookie: cookie
            }
        };

        const response = await axios.get(this.ticketLookupUrl + number, axiosOpts);
        const { window } = new JSDOM(response.data, {});

        const element = await window.document.querySelector("#TicketLookup");

        // Generate the values for the ticket from the html element
        const values = this.getTicketValues(element);

        const returnObj = values.reduce((acc: any, el, i) => {
            acc[TICKET_RESPONSE_KEYS[i]] = el;
            return acc;
        }, {}) as WSFTicketData;

        // FIXME maybe a better validation solution.

        if (!returnObj) {
            return ["Unable to cast ticket object to correct type.", null];
        }
        const errors = validateObjectHasOnlyProperties(TICKET_RESPONSE_KEYS, returnObj);
        if (errors.length !== 0) {
            return [`Ticket information ill formatted: ${errors.toLocaleString}`, null];
        }
        return [null, returnObj];
    }

    private async getWSFTicketCookie(): Promise<string> {
        const response = await axios.get(this.ticketLandingPageUrl, {
            maxRedirects: 0,
            validateStatus: (s) => s === 302
        });
        // FIXME this type cast may be unsafe. Hasnt broken yet.
        const cookieStrings = response.headers["set-cookie"] as string[];
        return cookieStrings.map((string) => string.split(" ")[0]).join(" ");
    }
}
anstosa commented 3 years ago

https://wave2go.wsdot.com/webstore/account/ticketLookup.aspx?VisualID=5503720127751822337886

anstosa commented 2 years ago

Added in e038368e6e62f19a00190e0b1fd0ccbd69ea8012