commit cd7c1f1e50f502ca35b0af3f00a3630178e2910a Author: Edward Betts Date: Tue Aug 8 16:17:50 2023 +0100 Initial commit. diff --git a/check.py b/check.py new file mode 100755 index 0000000..14504a7 --- /dev/null +++ b/check.py @@ -0,0 +1,137 @@ +#!/usr/bin/python3 + +import os +from datetime import datetime + +from playwright.sync_api import Playwright, Request, expect, sync_playwright +from rich.pretty import pprint + +skip_domains = { + "cdn.linkedin.oribi.io", + "cdn.evgnet.com", + "zdassets.com", + "doubleclick.net", + "linkedin.com", + "facebook", + "adservice.google.com", + "google", + "bing", + "eurotunnel.report-uri.com", +} + +skip_content_type = {"image", "text/css", "application/x-woff", "text/javascript"} + +data_loc = os.path.expanduser("~/lib/data/eurotunnel") + +outbound_label = "Select your outbound tickets from Folkestone to Calais" +return_label = "Select your Return tickets from Calais to Folkestone" + +choose_your_tickets = "https://www.eurotunnel.com/book/ChooseYourTickets/(0)" + + +def data_filename(page_type: str, ext: str = "html") -> str: + """Filename to use for saving data.""" + now_str = datetime.utcnow().strftime("%Y-%m-%d_%H%M%S") + + return os.path.join(data_loc, now_str + f"_{page_type}.{ext}") + + +class HandleResponse: + got_outbound: bool = False + + def requestfinished(self, request: Request) -> None: + """Show details of finished request.""" + if any(d in request.url for d in skip_domains): + return + + if "tunnel" not in request.url: + return + + response = request.response() + reply_ct = response.headers.get("content-type") + if reply_ct and any(reply_ct.startswith(ct) for ct in skip_content_type): + return + + url = request.url + + show = { + "url": url, + "method": request.method, + "request_headers": request.headers, + "response_headers": response.headers, + } + if request.method == "POST": + show["post_data"] = request.post_data + pprint(show) + + if not request.method == "GET" or not url.startswith(choose_your_tickets): + return + + if url == choose_your_tickets + "?d=o": + filename = data_filename("outbound") + + with open(filename, "wb") as out: + out.write(response.body()) + + if url == choose_your_tickets + "?d=r": + filename = data_filename("return") + + with open(filename, "wb") as out: + out.write(response.body()) + + +def run(playwright: Playwright) -> None: + """Launch browser and search for options.""" + browser = playwright.chromium.launch(headless=False) + context = browser.new_context() + page = context.new_page() + + hr = HandleResponse() + page.on("requestfinished", hr.requestfinished) + + page.goto("https://www.eurotunnel.com/uk/") + page.get_by_role("button", name="Accept All Cookies").click() + page.get_by_role("textbox", name="Outbound").click() + page.get_by_role("row", name="August 2023").get_by_role("cell").nth(1).click() + page.get_by_role("cell", name="29").click() + page.get_by_role("textbox", name="Return").click() + page.get_by_role("row", name="September 2023").get_by_role("cell").nth(2).click() + page.get_by_role("cell", name="6", exact=True).click() + page.get_by_role("button", name="Search and book").click() + page.get_by_label("Enter your vehicle registration").fill("KE69HRR") + page.get_by_role("button", name="Find").click() + page.get_by_text("No", exact=True).first.click() + page.get_by_text("No", exact=True).nth(1).click() + page.get_by_text("Conventional (Diesel/Petrol)").click() + page.get_by_role("combobox", name="Country of residence").select_option("GB") + page.get_by_role("button", name="Continue").click() + + expect(page.get_by_text(outbound_label)).to_be_visible() + + filename = data_filename("outbound_prices") + + with open(filename, "w") as out: + out.write(page.content()) + + page.locator("#slots div").filter(has_text="12:00To").locator("span").click() + page.locator("div:nth-child(5) > .times > div:nth-child(2) > .radio-button").click() + page.locator( + "div:nth-child(17) > .d-flex > div > .body > .radio-button" + ).first.click() + page.get_by_role("button", name="Confirm and choose return ticket").click() + + expect(page.get_by_text(return_label)).to_be_visible() + + filename = data_filename("return_prices") + + with open(filename, "w") as out: + out.write(page.content()) + + page.close() + + context.close() + browser.close() + + +with sync_playwright() as playwright: + run(playwright) diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..5304790 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,61 @@ + + + + + + + + +{% macro time(t) %}{{ t[:2] + ":" + t[2:] }}{% endmacro %} + + +
+ +

All Flexi Long Stay tickets are £269

+

Pay an extra £40 to make a price return refundable.

+ +
+
+ +

Updated: {{ out_ts.strftime("%a, %-d %b %Y at %H:%M") }}

+ + + + + + +{% for t in out %} + + + + + +{% endfor %} +
DepartArrivePrice
{{ time(t.dep) }}{{ time(t.arr) }}£{{ t.price }}
+
+ +
+ +

Updated: {{ back_ts.strftime("%a, %-d %b %Y at %H:%M") }}

+ + + + + + +{% for t in back %} + + + + + +{% endfor %} +
DepartArrivePrice
{{ time(t.dep) }}{{ time(t.arr) }}£{{ t.price }}
+
+ + +
+
+ + + diff --git a/web_view.py b/web_view.py new file mode 100755 index 0000000..135eab8 --- /dev/null +++ b/web_view.py @@ -0,0 +1,96 @@ +#!/usr/bin/python3 + +import os +from dataclasses import dataclass +from datetime import UTC, datetime +from decimal import Decimal + +import flask +import lxml.html +import pytz + +app = flask.Flask(__name__) +app.debug = True + +data_loc = os.path.expanduser("~/lib/data/eurotunnel") + + +def get_filename(direction: str) -> tuple[datetime, str]: + """Most recent list of outbound prices.""" + end = f"_{direction}.html" + most_recent = max([f for f in os.listdir(data_loc) if f.endswith(end)]) + filename = os.path.join(data_loc, most_recent) + timestamp = most_recent.removesuffix(end) + dt_utc = datetime.strptime(timestamp, "%Y-%m-%d_%H%M%S").replace(tzinfo=UTC) + dt = dt_utc.astimezone(pytz.timezone("Europe/London")) + return (dt, filename) + + +@dataclass +class Train: + """Eurostar train.""" + + dep: str + arr: str + price: Decimal | None = None + + +def get_tickets(filename: str) -> list[Train]: + """Get trains and prices.""" + tree = lxml.html.parse(filename) + root = tree.getroot() + + trains = [] + by_time = {} + + for mission in root.findall(".//div[@data-mission]"): + dep_text = mission.findtext(".//b") + assert dep_text + dep = dep_text.replace(":", "") + arr_text = mission.findtext(".//small") + assert arr_text + arr = arr_text.removeprefix("Arrive ").replace(":", "") + item = Train(dep=dep, arr=arr) + trains.append(item) + by_time[dep] = item + + for ticket in root.xpath("//div[contains(@class, 'ticket')]"): + classes = ticket.get("class").split(" ") + if "ticket" not in classes: + continue + ticket_class = classes[-1] + if ticket_class != "standard": + continue + + mission_time = ticket.getparent().getparent().get("data-mission-time") + + onclick = ticket.get("onclick").split(",") + price = Decimal(onclick[2][1:-1]) + + by_time[mission_time].price = price + + return trains + + +@app.route("/") +def index() -> str: + """Index.""" + out_ts, out_filename = get_filename("outbound") + out = get_tickets(out_filename) + out = [t for t in out if t.dep > "0800" and "0700" < t.arr < "2200"] + + back_ts, back_filename = get_filename("return") + back = get_tickets(back_filename) + back = [t for t in back if t.dep > "1100" and "0700" < t.arr < "2200"] + + return flask.render_template( + "index.html", + out_ts=out_ts, + out=out, + back_ts=back_ts, + back=back, + ) + + +if __name__ == "__main__": + app.run(host="0.0.0.0")