SAS bonus watch userscript
Tampermonkey/Violentmonkey userscript that watches SAS EuroBonus award availability from a sas.se tab and notifies via ntfy, Discord, Slack, webhook or desktop when matching seats appear. Moved here from sas-bonus-finder; update URLs point at this public repo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f1acd53a9c
4 files changed
+1254
No files matched your search
@@ -0,0 +1,211 @@
|
||||
// node --test
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const core = require('./sas-bonus-watch.user.js');
|
||||
|
||||
// Shapes captured from sas.se on 2026-10-12
|
||||
const ROUTES_ARN_HND_1209 = [
|
||||
{ flightId: 'SK1415-20261209-ARN-CPH', arrivalDate: '2026-12-10', arrivalTime: '07:55', departureDate: '2026-12-09',
|
||||
departureTime: '06:00', availability: { AG: 0, AP: 1, AB: 0 }, flyTime: 830, totalTime: 1075, noOfFlights: 2, haulType: 'LH' },
|
||||
{ flightId: 'SK1417-20261209-ARN-CPH', arrivalDate: '2026-12-10', arrivalTime: '07:55', departureDate: '2026-12-09',
|
||||
departureTime: '07:00', availability: { AG: 0, AP: 1, AB: 0 }, flyTime: 830, totalTime: 1015, noOfFlights: 2, haulType: 'LH' },
|
||||
];
|
||||
|
||||
const day = (date, seats) => ({ date, key: +date.replace(/-/g, '').slice(2), availableSeatsTotal: Object.values(seats).reduce((a, b) => a + b, 0), ...seats });
|
||||
const calendarPayload = (outbound, inbound) => [{ airportCode: 'HND', cityName: 'Tokyo', flightClasses: ['AG', 'AP'],
|
||||
availability: { outbound: outbound.map(([d, s]) => day(d, s)), inbound: inbound.map(([d, s]) => day(d, s)) } }];
|
||||
|
||||
class Blocked extends Error {}
|
||||
|
||||
function setup(watches, responses, { prefilter = true, now } = {}) {
|
||||
const cfg = core.validateConfig({ settings: { prefilter }, notify: [], watches });
|
||||
const calls = [];
|
||||
const sent = [];
|
||||
const answer = (key) => {
|
||||
calls.push(key);
|
||||
const r = responses[key];
|
||||
if (r instanceof Error) throw r;
|
||||
return r || [];
|
||||
};
|
||||
const api = {
|
||||
calendar: async (o, d, direct, pax) => core.parseCalendar(answer(`cal ${o} ${d}`), d),
|
||||
flights: async (o, d, dayIso) => answer(`routes ${o} ${d} ${dayIso}`).map(core.parseFlight),
|
||||
};
|
||||
const state = {};
|
||||
const w = core.createWatcher({ cfg, api, state, save() {}, log() {}, ...(now && { now }),
|
||||
notify: async (title, body) => { sent.push({ title, body }); return true; } });
|
||||
return { w, calls, sent, state };
|
||||
}
|
||||
|
||||
// one-way style by default so single-leg behaviour is easy to assert
|
||||
const watch = (extra = {}) => ({ name: 'tokyo', origins: ['ARN'], destinations: ['HND'],
|
||||
from: '2026-12-01', to: '2026-12-20', cabins: ['AP'], require_both_legs: false, ...extra });
|
||||
|
||||
const baseResponses = () => ({
|
||||
'cal ARN HND': calendarPayload([['2026-12-09', { AP: 1 }], ['2026-12-08', { AG: 5 }]], [['2027-01-05', { AG: 4 }]]),
|
||||
'routes ARN HND 2026-12-09': ROUTES_ARN_HND_1209,
|
||||
});
|
||||
|
||||
test('new seats notify once, unchanged calendar uses cache', async () => {
|
||||
const { w, calls, sent } = setup([watch()], baseResponses());
|
||||
assert.equal(await w.runCycle(), 2);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.match(sent[0].body, /SAS Plus ×1/);
|
||||
assert.match(sent[0].body, /OUT ARN→HND Wed 09 Dec 06:00→Thu 10 Dec 07:55, 1 stop/);
|
||||
assert.deepEqual(calls, ['cal ARN HND', 'routes ARN HND 2026-12-09']); // 12-08 has only AG -> never fetched
|
||||
|
||||
calls.length = 0;
|
||||
assert.equal(await w.runCycle(), 0);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.deepEqual(calls, ['cal ARN HND']);
|
||||
});
|
||||
|
||||
test('disappear then reappear notifies again', async () => {
|
||||
const r = baseResponses();
|
||||
const { w, sent } = setup([watch()], r);
|
||||
await w.runCycle();
|
||||
const full = r['cal ARN HND'];
|
||||
r['cal ARN HND'] = calendarPayload([], []);
|
||||
await w.runCycle();
|
||||
r['cal ARN HND'] = full;
|
||||
await w.runCycle();
|
||||
assert.equal(sent.length, 2);
|
||||
});
|
||||
|
||||
test('min_seats, cabins and max_stops filter', async () => {
|
||||
assert.equal(await setup([watch({ min_seats: 2 })], baseResponses()).w.runCycle(), 0);
|
||||
assert.equal(await setup([watch({ cabins: ['AB'] })], baseResponses()).w.runCycle(), 0);
|
||||
assert.equal(await setup([watch({ max_stops: 0 })], baseResponses()).w.runCycle(), 0);
|
||||
});
|
||||
|
||||
test('require_both_legs (the default) and max_nights within one travel window', async () => {
|
||||
const trip = { from: '2026-12-01', to: '2027-01-10', require_both_legs: undefined };
|
||||
const r = baseResponses();
|
||||
assert.equal(await setup([watch(trip)], r).w.runCycle(), 0); // no AP seats home
|
||||
|
||||
r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 1 }]], [['2027-01-05', { AP: 2 }]]);
|
||||
r['routes HND ARN 2027-01-05'] = [{ ...ROUTES_ARN_HND_1209[0], flightId: 'SK984-20270105-HND-CPH',
|
||||
departureDate: '2027-01-05', arrivalDate: '2027-01-05', availability: { AP: 2 } }];
|
||||
const both = setup([watch(trip)], r);
|
||||
assert.equal(await both.w.runCycle(), 3);
|
||||
assert.match(both.sent[0].body, /RET HND→ARN/);
|
||||
|
||||
assert.equal(await setup([watch({ ...trip, max_nights: 20 })], r).w.runCycle(), 0); // 27 nights
|
||||
});
|
||||
|
||||
test('blocked request aborts without committing calendar or notifying', async () => {
|
||||
const r = baseResponses();
|
||||
r['routes ARN HND 2026-12-09'] = new Blocked('HTTP 403');
|
||||
const { w, sent, state } = setup([watch()], r);
|
||||
await assert.rejects(w.runCycle(), Blocked);
|
||||
assert.deepEqual(state.calendar, {});
|
||||
assert.equal(sent.length, 0);
|
||||
});
|
||||
|
||||
test('prefilter off fetches every day in both directions', async () => {
|
||||
const { w, calls } = setup([watch({ from: '2026-12-08', to: '2026-12-09' })], baseResponses(), { prefilter: false });
|
||||
assert.equal(await w.runCycle(), 2);
|
||||
assert.deepEqual(calls, ['routes ARN HND 2026-12-08', 'routes ARN HND 2026-12-09',
|
||||
'routes HND ARN 2026-12-08', 'routes HND ARN 2026-12-09']);
|
||||
});
|
||||
|
||||
test('inbound flights in the window are found too', async () => {
|
||||
const r = baseResponses();
|
||||
r['cal ARN HND'] = calendarPayload([], [['2026-12-12', { AP: 2 }]]);
|
||||
r['routes HND ARN 2026-12-12'] = [{ ...ROUTES_ARN_HND_1209[0], flightId: 'SK984-20261212-HND-CPH',
|
||||
departureDate: '2026-12-12', arrivalDate: '2026-12-12', availability: { AP: 2 } }];
|
||||
const { w, sent } = setup([watch()], r);
|
||||
assert.equal(await w.runCycle(), 1);
|
||||
assert.match(sent[0].body, /RET HND→ARN Sat 12 Dec/);
|
||||
});
|
||||
|
||||
test('one calendar request serves both directions', async () => {
|
||||
const a = watch({ name: 'a' });
|
||||
const b = watch({ name: 'b', origins: ['HND'], destinations: ['ARN'], from: '2027-01-01', to: '2027-01-10', cabins: ['AG'] });
|
||||
const { w, calls } = setup([a, b], baseResponses());
|
||||
await w.runCycle();
|
||||
assert.equal(calls.filter((c) => c.startsWith('cal')).length, 1);
|
||||
});
|
||||
|
||||
test('config validation', () => {
|
||||
assert.ok(core.validateConfig(core.DEFAULT_CONFIG));
|
||||
assert.throws(() => core.validateConfig({ watches: [{ origins: 'ARNX', destinations: ['HND'], from: '2026-12-01', to: '2026-11-01' }] }),
|
||||
/"to" is before "from"[\s\S]*not a 3-letter airport code/);
|
||||
assert.throws(() => core.validateConfig({ settings: { min_request_interval: 5 }, watches: [watch()] }), /at least 20 seconds/);
|
||||
assert.throws(() => core.validateConfig({ watches: [watch({ min_nights: 9, max_nights: 5 })] }), /max nights is below min nights/);
|
||||
|
||||
// table input: comma-separated airports, empty optional numbers, disabled rows dropped
|
||||
const cfg = core.validateConfig({ watches: [
|
||||
watch({ origins: 'arn, cph', min_nights: '', max_stops: '' }),
|
||||
watch({ name: 'off', enabled: false }),
|
||||
] });
|
||||
assert.deepEqual(cfg.watches.map((w) => w.name), ['tokyo']);
|
||||
assert.deepEqual(cfg.watches[0].origins, ['ARN', 'CPH']);
|
||||
assert.equal(cfg.watches[0].min_nights, null);
|
||||
assert.equal(cfg.watches[0].max_stops, null);
|
||||
});
|
||||
|
||||
test('old outbound/inbound configs are migrated to one window', () => {
|
||||
const cfg = core.validateConfig({ watches: [{ name: 'old', origins: ['ARN'], destinations: ['HND'],
|
||||
outbound: { from: '2026-12-15', to: '2026-12-23' }, inbound: { from: '2027-01-02', to: '2027-01-10' } }] });
|
||||
assert.deepEqual(cfg.watches[0].window, { from: '2026-12-15', to: '2027-01-10' });
|
||||
assert.equal(cfg.watches[0].require_both_legs, true);
|
||||
});
|
||||
|
||||
const withSeats = (n) => ROUTES_ARN_HND_1209.map((f) => ({ ...f, availability: { AP: n } }));
|
||||
|
||||
test('seat drops are tracked against the peak, gone matches stay listed in red', async () => {
|
||||
const r = baseResponses();
|
||||
r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 20 }]], []);
|
||||
r['routes ARN HND 2026-12-09'] = withSeats(10);
|
||||
const { w, state, sent } = setup([watch()], r);
|
||||
await w.runCycle();
|
||||
|
||||
r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 16 }]], []); // calendar changed -> details refetched
|
||||
r['routes ARN HND 2026-12-09'] = withSeats(8);
|
||||
await w.runCycle();
|
||||
const e = state.matches.tokyo['outbound|SK1415-20261209-ARN-CPH|AP'];
|
||||
assert.deepEqual([e.status, e.seats, e.peak], ['active', 8, 10]);
|
||||
assert.equal(state.events[0].kind, 'down');
|
||||
assert.match(state.events[0].line, /SAS Plus ×10 → ×8/);
|
||||
assert.equal(sent.length, 1); // a drop is shown, not alerted
|
||||
|
||||
r['cal ARN HND'] = calendarPayload([], []);
|
||||
await w.runCycle();
|
||||
const gone = state.matches.tokyo['outbound|SK1415-20261209-ARN-CPH|AP'];
|
||||
assert.deepEqual([gone.status, gone.seats, gone.peak], ['gone', 8, 10]);
|
||||
assert.equal(state.events[0].kind, 'gone');
|
||||
|
||||
r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 4 }]], []);
|
||||
r['routes ARN HND 2026-12-09'] = withSeats(2);
|
||||
await w.runCycle();
|
||||
assert.equal(state.matches.tokyo['outbound|SK1415-20261209-ARN-CPH|AP'].status, 'active');
|
||||
assert.equal(state.events[0].kind, 'back');
|
||||
assert.equal(sent.length, 2); // back again -> alerted again
|
||||
});
|
||||
|
||||
test('lost matches are forgotten after two weeks', async () => {
|
||||
let t = Date.parse('2026-10-12T12:00:00Z');
|
||||
const r = baseResponses();
|
||||
const { w, state } = setup([watch()], r, { now: () => t });
|
||||
await w.runCycle();
|
||||
r['cal ARN HND'] = calendarPayload([], []);
|
||||
await w.runCycle();
|
||||
assert.equal(Object.values(state.matches.tokyo).every((e) => e.status === 'gone'), true);
|
||||
t += 15 * 86400000;
|
||||
await w.runCycle();
|
||||
assert.deepEqual(state.matches.tokyo, {});
|
||||
});
|
||||
|
||||
test('a leg whose return disappeared is marked unpaired, not gone', async () => {
|
||||
const r = baseResponses();
|
||||
r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 1 }]], [['2026-12-20', { AP: 1 }]]);
|
||||
r['routes HND ARN 2026-12-20'] = [{ ...ROUTES_ARN_HND_1209[0], flightId: 'SK984-20261220-HND-CPH',
|
||||
departureDate: '2026-12-20', arrivalDate: '2026-12-20', availability: { AP: 1 } }];
|
||||
const { w, state } = setup([watch({ require_both_legs: true })], r);
|
||||
assert.equal(await w.runCycle(), 3);
|
||||
r['cal ARN HND'] = calendarPayload([['2026-12-09', { AP: 1 }]], []);
|
||||
await w.runCycle();
|
||||
const st = Object.fromEntries(Object.entries(state.matches.tokyo).map(([k, e]) => [k.split('|')[1], e.status]));
|
||||
assert.deepEqual(st, { 'SK1415-20261209-ARN-CPH': 'unpaired', 'SK1417-20261209-ARN-CPH': 'unpaired', 'SK984-20261220-HND-CPH': 'gone' });
|
||||
});
|
||||
Reference in new issue
Block a user