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:
Måns SivertssonandClaude Opus 5.5 committed 2026-10-12 16:06:40 +02:00
commit f1acd53a9c
4 files changed
+1254

No files matched your search

+211
View File
@@ -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' });
});