Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture/SCRAPERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ venue registry.
|---|---|---|---|
| **Chicago** | `chicago75.se/evenemang` | swing-dance studio → `'all'` | Keep every event. |
| **S:ta Clara Bierhaus** | `staclara.se/calendar.html` | jazz/blues pub → `'roster'` | Events live in custom `<calendar-event>` elements under a month `<h2>` header; `BAND 19-22<BR>genre` lines. |
| **Norrport** | `norrport.se/kalender/` | mixed venue → `'roster'` | Grid of `.np-grid-card` divs; date from booking-button onclick; band name from title before `\|`. |
| **Norrport** | `norrport.se/kalender/` | mixed venue → `'roster'` | Grid of `.np-grid-card` divs; date from the booking button's `data-date`; band name from title before `\|` (emoji and trailing `!`/`:` stripped). Cards usually list a start time only, so a missing `end` is filled as start + 3h, the venue's usual slot. Private ("Abonnerat") and closed days have no bookable button and are skipped. |
| **Årstaliden** | `arstablick.com/Lindyhop.html` | Lindy night → `'all'` | Static `<li>` list; year from page text ("sommaren 2026"); `parse(html, refDate)` for test determinism. |
| **Swing Magnifique (band)** | `swingmagnifique.com/gigs` | band gig list → `'all'` | **Hand-entered** — Bandzoogle page structure is complex and fragile; gigs added directly to `oneoffs.csv`. Cross-venue (Georgian House, S:t Eriks Jazzbar). |

Expand Down
1,418 changes: 962 additions & 456 deletions scripts/scrapers/fixtures/norrport.html

Large diffs are not rendered by default.

50 changes: 34 additions & 16 deletions scripts/scrapers/sources/norrport.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
// Source: Norrport live-music calendar (norrport.se/kalender/).
//
// The page is a grid of `.np-grid-card` divs, each containing:
// - a title in `h3.np-grid-title > a` ("Old Boy Stompers | Norrport Live")
// - a meta line in `p.np-grid-meta` ("📅 Torsdag 18 juni kl 19:00 - 22:00")
// - a booking button whose onclick carries the ISO date:
// openBookingForEvent('2026-06-18', …)
// - a title in `h3.np-grid-title > a` ("Old Boy Stompers! 🎉| Norrport Live")
// - a meta line in `p.np-grid-meta` ("📅 Torsdag 29 okt kl 19:00")
// - a booking button whose `data-date` carries the ISO date:
// <button class="np-grid-btn-book" data-date="2026-10-29" …>
// - an event detail link in the title anchor
//
// The meta line carries a start time and, only sometimes, an end ("kl 19:00 -
// 22:00"). Most nights list the start alone, and `end` is required downstream
// (the ICS feed and JSON-LD both compute from it), so a missing end is filled
// with the venue's usual slot — see SLOT_HOURS.
//
// Cards without a booking button (STÄNGT/closed days) are skipped.
// Hidden cards (np-hidden-grid, behind "load more") are real future events.
//
Expand All @@ -25,24 +30,39 @@ const ORGANIZER = 'Norrport';

const pad = (n) => String(n).padStart(2, '0');

function parseTimeRange(text) {
const m = text.match(/kl\s+(\d{1,2}):(\d{2})\s*[-–]\s*(\d{1,2}):(\d{2})/);
// Norrport's shows run three hours: every Norrport row in oneoffs.csv is
// 19:00–22:00 or 20:00–23:00. Used only when the card gives a start alone.
const SLOT_HOURS = 3;

// "… kl 19:00" or "… kl 19:00 - 22:00" -> { start, end }. When the card has no
// end, `end` is start + SLOT_HOURS (wrapping past midnight, which the data
// contract allows). null if there is no valid start time.
function parseTimes(text) {
const m = text.match(/kl\s+(\d{1,2}):(\d{2})(?:\s*[-–]\s*(\d{1,2}):(\d{2}))?/);
if (!m) return null;
const sh = Number(m[1]);
const sm = Number(m[2]);
if (sh > 23 || sm > 59) return null;
const start = `${pad(sh)}:${pad(sm)}`;
if (m[3] === undefined) {
return { start, end: `${pad((sh + SLOT_HOURS) % 24)}:${pad(sm)}` };
}
const eh = Number(m[3]);
const em = Number(m[4]);
if (sh > 23 || eh > 23 || sm > 59 || em > 59) return null;
return { start: `${pad(sh)}:${pad(sm)}`, end: `${pad(eh)}:${pad(em)}` };
if (eh > 23 || em > 59) return null;
return { start, end: `${pad(eh)}:${pad(em)}` };
}

function extractBand(title) {
let name = title.split('|')[0].trim()
let name = title.split('|')[0]
.replace(/[\p{Extended_Pictographic}\uFE0F\u200D]/gu, '')
.replace(/[‘’]/g, "'")
.replace(/[“”]/g, '"');
.replace(/[“”]/g, '"')
.trim();
name = name.replace(/\s*(live\s+)?(at|på)\s+norrport!?\s*$/i, '');
name = name.replace(/^konsert\s+med\s+/i, '');
return name.trim();
// Decorative trailing punctuation: "Old Boy Stompers! 🎉", "Selander Quartet:".
return name.replace(/[\s!:]+$/, '').trim();
}

export function parse(html) {
Expand All @@ -54,13 +74,11 @@ export function parse(html) {

const bookBtn = card.find('button.np-grid-btn-book');
if (!bookBtn.length) return;
const onclick = bookBtn.attr('onclick') ?? '';
const dateMatch = onclick.match(/openBookingForEvent\('(\d{4}-\d{2}-\d{2})'/);
if (!dateMatch) return;
const date = dateMatch[1];
const date = bookBtn.attr('data-date') ?? '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return;

const meta = card.find('p.np-grid-meta').text();
const time = parseTimeRange(meta);
const time = parseTimes(meta);
if (!time) return;

const titleEl = card.find('h3.np-grid-title a').first();
Expand Down
106 changes: 83 additions & 23 deletions scripts/scrapers/sources/norrport.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,51 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as cheerio from 'cheerio';
import { parse, relevance } from './norrport.mjs';

const here = path.dirname(fileURLToPath(import.meta.url));
const html = readFileSync(path.join(here, '../fixtures/norrport.html'), 'utf-8');
const events = parse(html);
const byDate = Object.fromEntries(events.map((e) => [e.date, e]));

// One card in the shape the live page uses, for cases the fixture doesn't
// happen to contain (explicit end times, title suffixes, midnight wrap).
const card = ({
date = '2026-11-05',
meta = '📅 Torsdag 5 nov kl 19:00',
title = 'Some Band | Norrport Live',
} = {}) => `
<div class="np-grid-card" data-np-grid-date="${date}">
<div class="np-grid-content">
<h3 class="np-grid-title"><a href="https://norrport.se/norrportlive/x-${date}/">${title}</a></h3>
<p class="np-grid-meta">${meta}</p>
<div class="np-grid-action-row">
<button type="button" class="np-grid-btn np-grid-btn-book" data-np-book-event="1" data-date="${date}">BOKA HÄR</button>
</div>
</div>
</div>`;

describe('norrport parser', () => {
it('parses the live calendar at all', () => {
// The markup changed once and the parser silently returned 0 events.
expect(events.length).toBeGreaterThan(50);
});

it('parses every bookable card', () => {
const bookable = cheerio.load(html)('.np-grid-card [data-np-book-event]').length;
expect(bookable).toBeGreaterThan(50);
expect(events).toHaveLength(bookable);
});

it('extracts Old Boy Stompers with the right fields', () => {
const e = byDate['2026-06-18'];
const e = byDate['2026-10-29'];
expect(e).toBeTruthy();
expect(e).toMatchObject({
id: 'norrport-2026-06-18',
id: 'norrport-2026-10-29',
name: 'Old Boy Stompers',
venueId: 'norrport',
date: '2026-06-18',
date: '2026-10-29',
start: '19:00',
end: '22:00',
music: 'live',
Expand All @@ -29,7 +58,7 @@ describe('norrport parser', () => {
});

it("extracts Josefin's New Orleans Gang", () => {
const e = byDate['2026-06-25'];
const e = byDate['2026-11-26'];
expect(e).toBeTruthy();
expect(e.name).toBe("Josefin's New Orleans Gang");
expect(e.start).toBe('19:00');
Expand All @@ -40,32 +69,63 @@ describe('norrport parser', () => {
expect(relevance).toBe('roster');
});

it('strips the venue suffix from titles', () => {
expect(byDate['2026-06-18']?.band).toBe('Old Boy Stompers');
expect(byDate['2026-06-17']?.band).toBe('Weyleyd');
// "på Norrport" suffix
expect(byDate['2026-07-04']?.band).toBe('En annan femma');
expect(byDate['2026-07-11']?.band).toBe('Bluesväder');
// "live at Norrport" suffix
expect(byDate['2026-07-15']?.band).toBe('Siberian Hotheads');
// "Konsert med" prefix
expect(byDate['2026-06-24']?.band).toBe('Larmet och Beckius, Bratt & Hansson');
it('strips decoration from titles', () => {
// trailing "!" and emoji
expect(byDate['2026-10-29']?.band).toBe('Old Boy Stompers');
// trailing colon
expect(byDate['2026-11-14']?.band).toBe('Selander Quartet');
// curly apostrophe
expect(byDate['2026-11-26']?.band).toBe("Josefin's New Orleans Gang");
for (const e of events) {
expect(e.name).not.toMatch(/[\p{Extended_Pictographic}️]/u);
expect(e.band).not.toMatch(/[!:]$/);
}
});

it('strips the venue suffix and the concert prefix', () => {
const band = (title) => parse(card({ title }))[0].band;
expect(band('Bluesväder på Norrport | Norrport Live')).toBe('Bluesväder');
expect(band('Siberian Hotheads live at Norrport')).toBe('Siberian Hotheads');
expect(band('Konsert med Larmet och Beckius, Bratt & Hansson')).toBe(
'Larmet och Beckius, Bratt & Hansson',
);
});

it('skips cards without a booking button (STÄNGT/closed)', () => {
const ids = events.map((e) => e.id);
expect(ids).not.toContain('norrport-2026-06-20');
expect(ids).not.toContain('norrport-2026-12-24');
expect(ids).not.toContain('norrport-2027-01-01');
});

it('skips entries without a parseable time range (quiz)', () => {
const metas = events.filter((e) => /quiz/i.test(e.name));
expect(metas).toHaveLength(0);
it('skips private events, whose booking button is disabled and has no date', () => {
// ABONNERAT: "Abonnerat (Slutet sällskap)".
expect(byDate['2026-11-28']).toBeUndefined();
});

it('skips cards with no start time', () => {
expect(parse(card({ meta: '📅 Torsdag 5 nov' }))).toHaveLength(0);
});

it('includes hidden-grid cards (behind load more)', () => {
expect(events.length).toBeGreaterThan(10);
expect(byDate['2026-09-09']).toBeTruthy();
expect(byDate['2026-09-09']?.name).toBe("Ballin' the Jack");
expect(byDate['2026-12-31']).toBeTruthy();
expect(byDate['2026-12-31']).toMatchObject({ start: '19:00', end: '22:00' });
expect(byDate['2027-04-30']).toBeTruthy();
});

it('keeps an explicit end time when the card has one', () => {
const [e] = parse(card({ meta: '📅 Torsdag 5 nov kl 19:00 - 21:30' }));
expect(e).toMatchObject({ start: '19:00', end: '21:30' });
});

it('fills a missing end with the usual three-hour slot', () => {
const [e] = parse(card({ meta: '📅 Lördag 7 nov kl 20:00' }));
expect(e).toMatchObject({ start: '20:00', end: '23:00' });
expect(byDate['2026-11-07']).toMatchObject({ start: '20:00', end: '23:00' });
});

it('wraps the filled end past midnight', () => {
const [e] = parse(card({ meta: '📅 Lördag 7 nov kl 23:00' }));
expect(e).toMatchObject({ start: '23:00', end: '02:00' });
});

it('leaves description empty', () => {
Expand All @@ -82,7 +142,7 @@ describe('norrport parser', () => {
});

it('uses per-event detail URLs, not the listing page', () => {
const e = byDate['2026-06-18'];
expect(e.url).toContain('/norrportlive/old-boy-stompers');
const e = byDate['2026-10-29'];
expect(e.url).toBe('https://norrport.se/norrportlive/old-boy-stompers-2026-10-29/');
});
});
Loading