From d0c0cb90d51958c4d97a13b67f0b7eb8ae1ee1f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 19:42:52 +0000 Subject: [PATCH] Read more occasions and presenter credits from venues' own words - Read "Director Conversation" and a discussion with someone named as an in conversation, keeping a "Q&A discussion" a Q&A and a "discussion panel" a panel - Read "will introduce the film(s)" as an introduction - Don't read a work put "in conversation with" a film as a guest - Read Ibraaz's own film page for occasions, not only its Ticket Tailor event, which leaves the introduction out - Note an OutSavvy event's "Hosted By" organiser when its description credits no one - Read "Presented in collaboration with X" as X, not "Presented", and note it on Savoy Systems venues Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBhsxUjNUGUbmiiJSyJM72 --- .../tests/__snapshots__/index.test.js.snap | 6 ++ .../tests/__snapshots__/index.test.js.snap | 8 +- cinemas/ibraaz.org/tests/transform.test.js | 74 +++++++++++++++++++ cinemas/ibraaz.org/transform.js | 15 +++- .../tests/__snapshots__/index.test.js.snap | 12 +++ .../tests/__snapshots__/index.test.js.snap | 8 +- .../tests/__snapshots__/index.test.js.snap | 4 +- .../tests/__snapshots__/index.test.js.snap | 8 +- .../tests/__snapshots__/index.test.js.snap | 9 ++- .../tests/__snapshots__/index.test.js.snap | 16 ++-- common/occasions.js | 58 +++++++++++++-- .../tests/transform.test.js | 39 ++++++++++ common/savoysystems.co.uk/transform.js | 40 +++++++++- common/tests/get-presenter-note.test.js | 9 +++ common/tests/occasions.test.js | 59 +++++++++++++++ common/utils.js | 18 +++++ docs/occasions.md | 38 ++++++---- .../tests/__snapshots__/index.test.js.snap | 8 +- sources/outsavvy.com/find-events.js | 36 +++++---- .../tests/__snapshots__/index.test.js.snap | 8 +- .../outsavvy.com/tests/find-events.test.js | 42 +++++++++++ .../tests/fixtures/hosted-by.html | 68 +++++++++++++++++ 22 files changed, 529 insertions(+), 54 deletions(-) create mode 100644 cinemas/ibraaz.org/tests/transform.test.js create mode 100644 sources/outsavvy.com/tests/fixtures/hosted-by.html diff --git a/cinemas/barbican.org.uk/tests/__snapshots__/index.test.js.snap b/cinemas/barbican.org.uk/tests/__snapshots__/index.test.js.snap index e666c1380..04da3d45c 100644 --- a/cinemas/barbican.org.uk/tests/__snapshots__/index.test.js.snap +++ b/cinemas/barbican.org.uk/tests/__snapshots__/index.test.js.snap @@ -1826,6 +1826,12 @@ Outdoor Cinema", "format": {}, "notes": "", "occasions": { + "inConversation": { + "guests": [ + "Emma Matthews", + "Louis Petit", + ], + }, "panel": true, }, "screen": "Cinema 3", diff --git a/cinemas/genesiscinema.co.uk/tests/__snapshots__/index.test.js.snap b/cinemas/genesiscinema.co.uk/tests/__snapshots__/index.test.js.snap index 9032bd8e8..5f013e672 100644 --- a/cinemas/genesiscinema.co.uk/tests/__snapshots__/index.test.js.snap +++ b/cinemas/genesiscinema.co.uk/tests/__snapshots__/index.test.js.snap @@ -480,7 +480,13 @@ exports[`Genesis Cinema retrieve and transform 1`] = ` "bookingUrl": "https://genesis.admit-one.co.uk/seats/?perfCode=11711", "format": {}, "notes": "", - "occasions": {}, + "occasions": { + "inConversation": { + "guests": [ + "Nick Walker", + ], + }, + }, "status": { "soldOut": false, }, diff --git a/cinemas/ibraaz.org/tests/transform.test.js b/cinemas/ibraaz.org/tests/transform.test.js new file mode 100644 index 000000000..027c09680 --- /dev/null +++ b/cinemas/ibraaz.org/tests/transform.test.js @@ -0,0 +1,74 @@ +const transform = require("../transform"); + +// Turang's film page, cut down to what the transform reads, in Ibraaz's own +// words from a November 2026 retrieve. +const TURANG_URL = "https://ibraaz.org/whats-on/turang"; +const turangPage = ` +
+

Turang

+

Bachtiar Siagian

+
+
+

With an introduction from Bunga Siagian, cultural producer, researcher, and daughter of Bachtiar Siagian, the screening presents the restored film as an act of narrative reclamation and an archive of decolonisation, created at a time of radical political upheaval in the country of its creation. This screening of Turang is curated by May Adadol Ingawanij with Ibraaz in conversation with the final chapter of Taring Padi’s Rakyat Pasti Menang at Ibraaz London, which runs until 22 November, 2026.

+
+
+ Book +
+`; + +// The Ticket Tailor event says nothing of the introduction. +const sourcedEvents = { + "tickettailor.com": [ + { + showingId: "tickettailor.com-2526567", + title: "Turang", + url: "https://www.tickettailor.com/events/ibraaz/2526567", + overview: { + duration: 7200000, + categories: [], + directors: [], + actors: [], + }, + performances: [ + { + time: new Date("2026-11-22T15:00:00Z").getTime(), + notes: "", + bookingUrl: "https://www.tickettailor.com/events/ibraaz/2526567", + status: {}, + accessibility: {}, + format: {}, + occasions: {}, + }, + ], + matchingHints: { overview: "Turang" }, + }, + ], +}; + +describe("Ibraaz transform", () => { + it("reads the occasions the film page describes", async () => { + const [movie] = await transform( + { moviePages: { [TURANG_URL]: turangPage } }, + sourcedEvents, + ); + + expect(movie.performances[0].occasions).toEqual({ + intro: { guests: ["Bunga Siagian"] }, + }); + }); + + it("keeps the occasions the listing found", async () => { + const listed = structuredClone(sourcedEvents); + listed["tickettailor.com"][0].performances[0].occasions = { qAndA: true }; + + const [movie] = await transform( + { moviePages: { [TURANG_URL]: turangPage } }, + listed, + ); + + expect(movie.performances[0].occasions).toEqual({ + intro: { guests: ["Bunga Siagian"] }, + qAndA: true, + }); + }); +}); diff --git a/cinemas/ibraaz.org/transform.js b/cinemas/ibraaz.org/transform.js index ae63452b2..572213f0a 100644 --- a/cinemas/ibraaz.org/transform.js +++ b/cinemas/ibraaz.org/transform.js @@ -2,6 +2,8 @@ const cheerio = require("cheerio"); const { getText, createOverview, + createOccasions, + mergeOccasions, generateShowingId, } = require("../../common/utils"); const attributes = require("./attributes"); @@ -103,11 +105,20 @@ async function transform({ moviePages }, sourcedEvents) { duration: sourcedEvent.overview.duration, }, // The event's performances already carry the booking URL, status and the - // accessibility and format read off the listing; the room they screen in - // is the one thing only the venue's own page knows. + // accessibility, format and occasions read off the listing. The room + // they screen in is only on the venue's own page, and so is the copy + // that names who is introducing - "With an introduction from Bunga + // Siagian" is in Turang's page and not its Ticket Tailor event - so the + // page's occasions are added beneath the listing's. performances: sourcedEvent.performances.map((performance) => ({ ...performance, screen: screen || undefined, + occasions: mergeOccasions( + createOccasions("", {}, overview, { + date: new Date(performance.time), + }), + performance.occasions, + ), })), matchingHints: { overview }, }); diff --git a/cinemas/ica.art/tests/__snapshots__/index.test.js.snap b/cinemas/ica.art/tests/__snapshots__/index.test.js.snap index 26bf40233..c21e9ff78 100644 --- a/cinemas/ica.art/tests/__snapshots__/index.test.js.snap +++ b/cinemas/ica.art/tests/__snapshots__/index.test.js.snap @@ -592,6 +592,12 @@ exports[`Institute of Contemporary Arts retrieve and transform 1`] = ` "format": {}, "notes": "", "occasions": { + "inConversation": { + "guests": [ + "Michael Witt", + ], + }, + "intro": true, "qAndA": true, }, "screen": "Cinema 1", @@ -1452,6 +1458,12 @@ exports[`Institute of Contemporary Arts retrieve and transform 1`] = ` "format": {}, "notes": "", "occasions": { + "inConversation": { + "guests": [ + "Alexander Carey-Morgan", + "Laurence Hills", + ], + }, "qAndA": true, }, "screen": "Cinema 1", diff --git a/cinemas/phoenixcinema.co.uk/tests/__snapshots__/index.test.js.snap b/cinemas/phoenixcinema.co.uk/tests/__snapshots__/index.test.js.snap index 9dcf5d3f5..2c2f832f6 100644 --- a/cinemas/phoenixcinema.co.uk/tests/__snapshots__/index.test.js.snap +++ b/cinemas/phoenixcinema.co.uk/tests/__snapshots__/index.test.js.snap @@ -69,6 +69,7 @@ exports[`Phoenix Cinema retrieve and transform 1`] = ` "format": {}, "notes": "This screening will be followed by a Q&A", "occasions": { + "intro": true, "qAndA": true, }, "screen": "1", @@ -107,6 +108,7 @@ exports[`Phoenix Cinema retrieve and transform 1`] = ` "format": {}, "notes": "This screening will be followed by a Q&A", "occasions": { + "intro": true, "qAndA": true, }, "screen": "1", @@ -789,7 +791,11 @@ exports[`Phoenix Cinema retrieve and transform 1`] = ` "format": {}, "notes": "This screening will be followed by a Q&A", "occasions": { - "qAndA": true, + "qAndA": { + "guests": [ + "Aengus Collins O'Malley", + ], + }, }, "screen": "1", "status": { diff --git a/cinemas/picturehouses.com-central/tests/__snapshots__/index.test.js.snap b/cinemas/picturehouses.com-central/tests/__snapshots__/index.test.js.snap index e2b54cb87..0cf2150a0 100644 --- a/cinemas/picturehouses.com-central/tests/__snapshots__/index.test.js.snap +++ b/cinemas/picturehouses.com-central/tests/__snapshots__/index.test.js.snap @@ -4023,7 +4023,9 @@ Q&A", "dimension": "2d", }, "notes": "Ad and Trailer Free", - "occasions": {}, + "occasions": { + "intro": true, + }, "screen": "1", "status": { "soldOut": false, diff --git a/cinemas/riocinema.org.uk/tests/__snapshots__/index.test.js.snap b/cinemas/riocinema.org.uk/tests/__snapshots__/index.test.js.snap index 9e97aeda8..8168714c7 100644 --- a/cinemas/riocinema.org.uk/tests/__snapshots__/index.test.js.snap +++ b/cinemas/riocinema.org.uk/tests/__snapshots__/index.test.js.snap @@ -395,7 +395,13 @@ No Ads or Trailers", Special Event No Ads or Trailers", "occasions": { - "qAndA": true, + "qAndA": { + "guests": [ + "Rabbi Herschel Gluck", + "Winstan Whitter", + "Elin Moe", + ], + }, }, "screen": "1", "status": { diff --git a/cinemas/thegardencinema.co.uk/tests/__snapshots__/index.test.js.snap b/cinemas/thegardencinema.co.uk/tests/__snapshots__/index.test.js.snap index d555563b7..203159bc8 100644 --- a/cinemas/thegardencinema.co.uk/tests/__snapshots__/index.test.js.snap +++ b/cinemas/thegardencinema.co.uk/tests/__snapshots__/index.test.js.snap @@ -434,7 +434,14 @@ The screening will be introduced.", "bookingUrl": "https://bookings.thegardencinema.co.uk/TheGardenCinema.dll/TSelectItems.waSelectItemsPrompt.TcsWebMenuItem_0.TcsWebTab_0.TcsPerformance_5958172.TcsSection_3060731", "format": {}, "notes": "The screening will be followed by a discussion.", - "occasions": {}, + "occasions": { + "inConversation": { + "guests": [ + "Dan Dooner", + "Ben Mills", + ], + }, + }, "status": { "soldOut": false, }, diff --git a/cinemas/thelexicinema.co.uk/tests/__snapshots__/index.test.js.snap b/cinemas/thelexicinema.co.uk/tests/__snapshots__/index.test.js.snap index 855c7d381..6e9dc89e3 100644 --- a/cinemas/thelexicinema.co.uk/tests/__snapshots__/index.test.js.snap +++ b/cinemas/thelexicinema.co.uk/tests/__snapshots__/index.test.js.snap @@ -1187,7 +1187,7 @@ exports[`The Lexi Cinema retrieve and transform 1`] = ` }, "bookingUrl": "https://thelexicinema.co.uk/TheLexiCinema.dll/Booking?Booking=TSelectItems.waSelectItemsPrompt.TcsWebMenuItem_0.TcsWebTab_0.TcsPerformance_9991762.TcsSection_5209705", "format": {}, - "notes": "", + "notes": "Presented in collaboration with Cinema Mentiré", "occasions": {}, "screen": "1", "status": { @@ -1201,7 +1201,7 @@ exports[`The Lexi Cinema retrieve and transform 1`] = ` }, "bookingUrl": "https://thelexicinema.co.uk/TheLexiCinema.dll/Booking?Booking=TSelectItems.waSelectItemsPrompt.TcsWebMenuItem_0.TcsWebTab_0.TcsPerformance_9991779.TcsSection_5209705", "format": {}, - "notes": "", + "notes": "Presented in collaboration with Cinema Mentiré", "occasions": {}, "screen": "1", "status": { @@ -1232,7 +1232,7 @@ exports[`The Lexi Cinema retrieve and transform 1`] = ` }, "bookingUrl": "https://thelexicinema.co.uk/TheLexiCinema.dll/Booking?Booking=TSelectItems.waSelectItemsPrompt.TcsWebMenuItem_0.TcsWebTab_0.TcsPerformance_9991803.TcsSection_5209705", "format": {}, - "notes": "", + "notes": "Presented in collaboration with Cinema Mentiré", "occasions": {}, "screen": "1", "status": { @@ -1246,7 +1246,7 @@ exports[`The Lexi Cinema retrieve and transform 1`] = ` }, "bookingUrl": "https://thelexicinema.co.uk/TheLexiCinema.dll/Booking?Booking=TSelectItems.waSelectItemsPrompt.TcsWebMenuItem_0.TcsWebTab_0.TcsPerformance_9991814.TcsSection_5209705", "format": {}, - "notes": "", + "notes": "Presented in collaboration with Cinema Mentiré", "occasions": {}, "screen": "1", "status": { @@ -1277,7 +1277,7 @@ exports[`The Lexi Cinema retrieve and transform 1`] = ` }, "bookingUrl": "https://thelexicinema.co.uk/TheLexiCinema.dll/Booking?Booking=TSelectItems.waSelectItemsPrompt.TcsWebMenuItem_0.TcsWebTab_0.TcsPerformance_9991784.TcsSection_5209705", "format": {}, - "notes": "", + "notes": "Presented in collaboration with Cinema Mentiré", "occasions": {}, "screen": "1", "status": { @@ -1291,7 +1291,7 @@ exports[`The Lexi Cinema retrieve and transform 1`] = ` }, "bookingUrl": "https://thelexicinema.co.uk/TheLexiCinema.dll/Booking?Booking=TSelectItems.waSelectItemsPrompt.TcsWebMenuItem_0.TcsWebTab_0.TcsPerformance_9991801.TcsSection_5209705", "format": {}, - "notes": "", + "notes": "Presented in collaboration with Cinema Mentiré", "occasions": {}, "screen": "1", "status": { @@ -2210,7 +2210,9 @@ exports[`The Lexi Cinema retrieve and transform 1`] = ` "bookingUrl": "https://thelexicinema.co.uk/TheLexiCinema.dll/Booking?Booking=TSelectItems.waSelectItemsPrompt.TcsWebMenuItem_0.TcsWebTab_0.TcsPerformance_9987157.TcsSection_5209807", "format": {}, "notes": "Accessible screening", - "occasions": {}, + "occasions": { + "intro": true, + }, "screen": "2", "status": { "soldOut": false, diff --git a/common/occasions.js b/common/occasions.js index 4141d39b3..85052848c 100644 --- a/common/occasions.js +++ b/common/occasions.js @@ -232,6 +232,23 @@ const routinePreview = const qAndA = String.raw`(?:q\s*&\s*a|q\s*and\s*a|screen\s?talk)`; +// "Switches Screening + Director Conversation" - a conversation billed by who +// is in it rather than with "in". Only the roles that are someone in the room +// for the film, so a "Film Conversation" (a strand name) isn't read as one. +const conversationRole = String.raw`(?:director|filmmaker|film-maker|writer|actor|actress|producer|artist|curator|composer|author|cast|creator)s?(?:['’]s?)?`; + +// Curators put works "in conversation with" one another: Ibraaz's Turang "is +// curated by May Adadol Ingawanij with Ibraaz in conversation with the final +// chapter of Taring Padi's Rakyat Pasti Menang", an exhibition. That is a +// dialogue between artworks, not someone in the room. +const conversationWithWork = + /\bin conversation with\s+(?:(?:the|an?|our|its|their|this|these)\s+)?(?:[\p{L}'’-]+\s+){0,3}?(?:exhibitions?|chapters?|installations?|works?|programmes?|seasons?|series|collections?|archives?|shows?)(?![\p{L}'’])/iu; + +const notInTheRoomOrAWork = new RegExp( + `${notInTheRoom.source}|${conversationWithWork.source}`, + "iu", +); + // Each rule finds one key. `names` names the detail its trailing phrase fills; // rules for the same key are tried in order and the first to match wins, so a // named rule is listed before the bare one it refines. @@ -257,7 +274,8 @@ const rules = [ { key: "qAndA", pattern: new RegExp( - String.raw`\b${qAndA}\s+(?:with|w\/|from|featuring|by)\s+`, + // "a Q&A discussion with Rabbi Herschel Gluck" (the Rio) is a Q&A + String.raw`\b${qAndA}(?:\s+(?:discussion|session))?\s+(?:with|w\/|from|featuring|by)\s+`, "i", ), names: "guests", @@ -271,15 +289,36 @@ const rules = [ { key: "inConversation", - pattern: /\bin conversation with\s+/i, + pattern: new RegExp( + String.raw`\b(?:in|${conversationRole})\s+conversation\s+with\s+`, + "i", + ), names: "guests", - unless: notInTheRoom, + unless: notInTheRoomOrAWork, }, + // A discussion only counts with someone named in it: "lead a discussion + // following this screening with director Nadine White" (the Lexi). A bare + // one is as often the audience talking - "A post-film discussion where every + // voice is welcome", the Lexi's "friendly film discussion group for + // seniors" - and a panel or Q&A discussion is a panel or a Q&A, as is + // Birkbeck's "post-screening discussion panel with Michal Oleszczyk". { key: "inConversation", - pattern: /\bin conversation\b/i, + pattern: new RegExp( + String.raw`(? { expect(movies[0].overview.directors).toEqual(["Hazel M", "Mae M"]); }); + + const transformSynopsis = (Synopsis) => + transform( + attributes, + { urlSlug: "TheLexiCinema.dll", tags: { notes: {} } }, + { + movieListPage: { + Events: [{ ...venueEvent(VENUE_BOOKING_URL), Synopsis }], + }, + moviePages: {}, + }, + {}, + ); + + // The Lexi's Gugu's World, its description's lines as the ld+json splits + // them + it("notes a partner the description credits", async () => { + const movies = await transformSynopsis( + "Presented in collaboration with Cine Brazil .\nThese screenings mark the UK premiere of Brazil's submission to next year's Academy Awards, following in the footsteps of The Secret Agent and I'm Still Here !", + ); + + expect(movies[0].performances[0].notes).toEqual( + "Presented in collaboration with Cine Brazil", + ); + }); + + it("doesn't note a synopsis in which someone presents something", async () => { + const movies = await transformSynopsis( + "Director Céline Sciamma presents a tender portrait of childhood.", + ); + + expect(movies[0].performances[0].notes).toEqual(""); + }); + + it("doesn't note the venue crediting itself", async () => { + const movies = await transformSynopsis("Presented by The Lexi Cinema."); + + expect(movies[0].performances[0].notes).toEqual(""); + }); }); diff --git a/common/savoysystems.co.uk/transform.js b/common/savoysystems.co.uk/transform.js index 5093349ff..426025bea 100644 --- a/common/savoysystems.co.uk/transform.js +++ b/common/savoysystems.co.uk/transform.js @@ -12,7 +12,9 @@ const { isPrivateHire, getText, convertNamesTextToList, + getPresenterNote, } = require("../../common/utils"); +const normalizeVenueName = require("../../common/normalize-venue-name"); const { extractPeopleNames } = require("../../common/extract-people"); const { parseDate } = require("./utils"); @@ -41,6 +43,37 @@ function extractDescriptionFromLdJson(html) { return description; } +// A partner credited in the description - "Presented in collaboration with +// Cine Brazil" (the Lexi) - and nowhere else, so it is kept as a note, where +// the website's club and festival pages can find it. +// +// Only a line that opens on "presented" is offered. getPresenterNote also reads +// "X presents ...", which in a synopsis is as often a director presenting a +// vision as anyone hiring the room. And as on OutSavvy, a venue crediting +// itself tells a reader nothing. +const PRESENTED_LINE = + /^(?:(?:this (?:event|screening|film) is|these screenings are) )?presented\b/i; + +function getPresentedNote(overview, attributes) { + const note = (overview || "") + .split("\n") + .map((line) => line.trim()) + .filter((line) => PRESENTED_LINE.test(line)) + .map((line) => getPresenterNote(line)) + .find(Boolean); + if (!note) return undefined; + + const presenter = note.replace(/^presented (?:in \w+ with|by)\s+/i, ""); + const venueNames = [ + attributes.name, + ...(attributes.alternativeNames || []), + ].filter(Boolean); + const isVenueItself = venueNames.some( + (name) => normalizeVenueName(name) === normalizeVenueName(presenter), + ); + return isVenueItself ? undefined : note; +} + function getStatus(performance) { return { soldOut: basicNormalize(performance.IsSoldOut) === "y" }; } @@ -129,6 +162,8 @@ async function transform(attributes, config, movieData, sourcedEvents) { ldJsonDescription || movie.Synopsis, ); + const presentedNote = getPresentedNote(overview, attributes); + return events.concat({ showingId: generateShowingId(attributes, movie.ID), title, @@ -143,7 +178,10 @@ async function transform(attributes, config, movieData, sourcedEvents) { const date = parseDate(performance); return createPerformance({ date, - notesList: getNotesList(performance, tags.notes), + notesList: [ + ...getNotesList(performance, tags.notes), + presentedNote, + ].filter(Boolean), url: performance.URL.toLowerCase().startsWith("http") ? performance.URL : `${attributes.domain}/${urlSlug}/${performance.URL}`, diff --git a/common/tests/get-presenter-note.test.js b/common/tests/get-presenter-note.test.js index 5b36bac52..3e6d390aa 100644 --- a/common/tests/get-presenter-note.test.js +++ b/common/tests/get-presenter-note.test.js @@ -31,6 +31,15 @@ describe("getPresenterNote", () => { ).toEqual("Presented by The Witch Of Popcorn"); }); + test("reads a partner from 'Presented in collaboration with Y', keeping its wording", () => { + // The Lexi, Gugu's World + expect( + getPresenterNote( + "Presented in collaboration with Cine Brazil . These screenings mark the UK premiere of Brazil's submission to next year's Academy Awards", + ), + ).toEqual("Presented in collaboration with Cine Brazil"); + }); + test("reads both names from a joint 'X in collaboration with Y' credit", () => { expect( getPresenterNote( diff --git a/common/tests/occasions.test.js b/common/tests/occasions.test.js index b5a519ddb..b8e177b5a 100644 --- a/common/tests/occasions.test.js +++ b/common/tests/occasions.test.js @@ -30,6 +30,11 @@ describe("createOccasions", () => { ["+ Q&A", { qAndA: true }], ["& Q&A", { qAndA: true }], ["UK PREMIERE Mirage + Q&A", { premiere: { region: "uk" }, qAndA: true }], + // Hart Shoreditch, through Eventbrite + [ + "Switches Screening + Director Conversation | Lumière Noire", + { inConversation: true }, + ], ])("reads '%s'", (title, expected) => { expect(createOccasions(title)).toEqual(expected); }); @@ -39,6 +44,8 @@ describe("createOccasions", () => { ["Interstellar"], ["Introducing the Dwights"], ["Members' Preview Screening: Wicked"], + // The Lexi's note for its seniors' group: the audience talking + ["Talking Pictures: A friendly film discussion group for seniors"], ])("finds nothing in '%s'", (title) => { expect(createOccasions(title)).toEqual({}); }); @@ -172,6 +179,35 @@ describe("createOccasions", () => { panel: { guests: ["Claire Nicolas", "Abla Kandalaft", "Jamal Awar"] }, }, ], + [ + // The Lexi, Black History Studies: Barrel Children. The introducer is + // named before the verb, where no name is read. + "an introduction of the films, and a discussion with a named guest", + "Charmaine Simpson, founder and CEO of Black History Studies , will introduce the films and lead a discussion following this screening with director Nadine White.", + { intro: true, inConversation: { guests: ["Nadine White"] } }, + ], + [ + // The Rio + "a Q&A discussion", + "The screening is followed by a Q&A discussion with Rabbi Herschel Gluck and filmmakers Winstan Whitter & Elin Moe.", + { + qAndA: { + guests: ["Rabbi Herschel Gluck", "Winstan Whitter", "Elin Moe"], + }, + }, + ], + [ + // Birkbeck + "a discussion panel", + "The programme will feature a post-screening discussion panel with Michal Oleszczyk following the documentary We Film the People (2013) by Ania Szczepanska.", + { panel: { guests: ["Michal Oleszczyk"] } }, + ], + [ + // The Phoenix + "an introduction of the film by someone named before it", + "Writer and researcher Manishita Dass will introduce the film, its themes and place within Ray’s oeuvre.", + { intro: true }, + ], [ "a live score", "This performance will feature a live score performed by Hugo Max.", @@ -220,6 +256,20 @@ describe("createOccasions", () => { "special guests in a filmed concert", "Featuring the extraordinary voice of Emma Kok, renowned tenors and special guests, this production is designed to delight audiences.", ], + [ + // Hart Shoreditch's Lumière Noire, through Eventbrite + "a discussion among the audience", + "💬 A post-film discussion where every voice is welcome", + ], + [ + // Sands Films + "a programme that will introduce films", + "The second selection of films will introduce films about the war made in Spain under Franco.", + ], + [ + "programme copy introducing audiences to something", + "This season introduces audiences to the cinema of Iran.", + ], [ "a film's history", "The film premiered (and won the Special Jury Prize) at the Venice Film Festival.", @@ -230,6 +280,15 @@ describe("createOccasions", () => { ).toEqual({}); }); + test("a work put in conversation with the film isn't a guest (Ibraaz, Turang)", () => { + // The second sentence names the performance's own date, so it is read. + const description = + "With an introduction from Bunga Siagian, cultural producer, researcher, and daughter of Bachtiar Siagian, the screening presents the restored film as an act of narrative reclamation and an archive of decolonisation, created at a time of radical political upheaval in the country of its creation. This screening of Turang is curated by May Adadol Ingawanij with Ibraaz in conversation with the final chapter of Taring Padi’s Rakyat Pasti Menang at Ibraaz London, which runs until 22 November, 2026."; + expect( + createOccasions("Turang", {}, description, at("2026-11-22T15:00Z")), + ).toEqual({ intro: { guests: ["Bunga Siagian"] } }); + }); + describe("a sentence naming a date", () => { const description = "Our screening on Saturday 21 November will be followed by a Q&A with director Rafael Manuel."; diff --git a/common/utils.js b/common/utils.js index 248ad3e4c..6b10ff594 100644 --- a/common/utils.js +++ b/common/utils.js @@ -798,7 +798,24 @@ const looksLikeOrganisation = (name) => { return /^[A-Z0-9]/.test(words[0]) && /^[A-Z0-9]/.test(words.at(-1)); }; +// A venue presenting with someone else names only its partner - "Presented in +// collaboration with Cine Brazil" (the Lexi). Read as the joint credit below, +// "Presented" would be taken for the first presenter. The partner didn't +// present it alone, so the note keeps the venue's own words. +const PRESENTED_WITH_PATTERN = new RegExp( + `^(?:(?:this (?:event|screening|film) is|these screenings are) )?presented (in (?:collaboration|partnership|association)) with\\s+(${ORGANISATION_NAME})`, + "i", +); + const getPresenterNote = (text) => { + const presentedWith = text.match(PRESENTED_WITH_PATTERN); + if (presentedWith) { + const [, connection, partner] = presentedWith; + return looksLikeOrganisation(partner) + ? `Presented ${connection.toLowerCase()} with ${partner.trim()}` + : null; + } + for (const pattern of PRESENTER_PATTERNS) { const match = text.match(pattern); if (!match) continue; @@ -1433,6 +1450,7 @@ module.exports = { createFormat, createOccasions, getTitleOccasions, + mergeOccasions, getValidFormat, getTitleFormat, removeMatchingHints, diff --git a/docs/occasions.md b/docs/occasions.md index 86c82601b..37c157c03 100644 --- a/docs/occasions.md +++ b/docs/occasions.md @@ -1,7 +1,7 @@ # Occasions -Clusterflick records what makes a performance more than a screening - someone -in the room, a live score, a premiere - on each performance, as +Clusterflick records what makes a performance more than a screening - someone in +the room, a live score, a premiere - on each performance, as `performances[].occasions`. The website decides which of them are worth promoting (a Q&A is a one-off at one venue and house style at another, which only the whole city can tell you); the transform only records what the venue @@ -29,20 +29,30 @@ it. | ----------------- | ------------------------------------------------------------------ | -------------------------------------------------------------- | | `intro` | Someone introduces the film | `guests` | | `qAndA` | A Q&A after the film (Barbican's "ScreenTalk" included) | `guests` | -| `inConversation` | An "in conversation" | `guests` | +| `inConversation` | An "in conversation", a "Director Conversation", or a discussion | `guests` | | `panel` | A panel discussion | `guests` | | `guestAppearance` | Someone appears, but not in a stated form ("in person", "special") | `guests` | | `liveScore` | Musicians play along with the film | `performers` | | `premiere` | A premiere | `region`: `uk`, `london`, `european`, `world`, `international` | | `preview` | Showing ahead of release | - | -`guestAppearance` is dropped whenever one of `intro`, `qAndA`, -`inConversation` or `panel` is found, since it then adds nothing. +`guestAppearance` is dropped whenever one of `intro`, `qAndA`, `inConversation` +or `panel` is found, since it then adds nothing. + +A discussion is only an `inConversation` when it names someone: "a discussion +with director Nadine White". An unnamed one is as often the audience talking ("A +post-film discussion where every voice is welcome"), and a "panel discussion" or +"Q&A discussion" is a panel or a Q&A. Equally, "in conversation with" a work - +"in conversation with the final chapter of Taring Padi's Rakyat Pasti Menang", +an exhibition - is a curator's phrase, not a guest. + +"Introduce" counts only with the film or the screening as its object ("will +introduce the films"), since programme copy introduces audiences to things. Names are as the venue wrote them, without the role around them: "a Q&A with -director Poh Si Teng" gives `["Poh Si Teng"]`. A name the rules can't be sure -of is left out rather than guessed - a single word ("Joel"), a run ending in a -job title ("BFI TV Programmer"), or anything after "of" or "for" ("editor of +director Poh Si Teng" gives `["Poh Si Teng"]`. A name the rules can't be sure of +is left out rather than guessed - a single word ("Joel"), a run ending in a job +title ("BFI TV Programmer"), or anything after "of" or "for" ("editor of Terminus") - so a list can be shorter than the venue's, but shouldn't hold anything that isn't a person or a group the venue named. @@ -52,8 +62,8 @@ Like accessibility and format, from three places, most reliable first: 1. **Structured listing data.** A venue that tags performances passes the tags as the second argument, as Indy Cinema Group does with its `intro` and `qa` - tags. `false` and `undefined` are dropped, so `{ qAndA: tags.includes("qa") }` - is fine; an unknown key or detail throws. + tags. `false` and `undefined` are dropped, so + `{ qAndA: tags.includes("qa") }` is fine; an unknown key or detail throws. 2. **The title and the performance notes.** `createOccasions` reads the title; `createPerformance` reads each note, since notes only exist once it has joined them. @@ -89,10 +99,10 @@ title it finds an introduction in a synopsis ("Keating is introduced to an all-boys school") and a guest in a booking footer ("no fee when tickets are booked in person"). So a sentence of the description only counts when: -- it is about the screening: "this screening", "will be followed by", "join - us", "post-screening" and the like; -- it isn't negated ("will NOT be in attendance") or in the past ("was - introduced by Tony Rayns"), and doesn't describe a recording; +- it is about the screening: "this screening", "will be followed by", "join us", + "post-screening" and the like; +- it isn't negated ("will NOT be in attendance") or in the past ("was introduced + by Tony Rayns"), and doesn't describe a recording; - if it names a date ("The screening on 11 November will be introduced by ..."), the performance is on that date, in London. A sentence naming no date applies to every performance of the showing: venues name the night when only one is diff --git a/sources/eventive.org/tests/__snapshots__/index.test.js.snap b/sources/eventive.org/tests/__snapshots__/index.test.js.snap index 011567cdd..890bf0089 100644 --- a/sources/eventive.org/tests/__snapshots__/index.test.js.snap +++ b/sources/eventive.org/tests/__snapshots__/index.test.js.snap @@ -4144,7 +4144,13 @@ exports[`Eventive Stockwell Park Community Centre retrieve and find events 1`] = "bookingUrl": "https://bccwhenthestarsmeettheocean.eventive.org/schedule/6a6c62d319f5d254f9b0299b", "format": {}, "notes": "Part of When the Stars Meet the Ocean", - "occasions": {}, + "occasions": { + "inConversation": { + "guests": [ + "Eiman Hussein", + ], + }, + }, "status": { "soldOut": false, }, diff --git a/sources/outsavvy.com/find-events.js b/sources/outsavvy.com/find-events.js index eb05a6e31..db4790127 100644 --- a/sources/outsavvy.com/find-events.js +++ b/sources/outsavvy.com/find-events.js @@ -27,26 +27,33 @@ const normalizeVenueName = require("../../common/normalize-venue-name"); // accepts a name shaped like an organisation, which is what keeps that from // reading marketing prose as a credit. // +// Every event page also names its organiser under "Hosted By", which is where +// Fashion Film Club is credited for its Secretary night at Rhizomatic! and +// nowhere else. The description's credit comes first, being what the organiser +// chose to write; the panel is the fallback. +// // A venue promoting its own night repeats its own name there, which tells a // reader nothing, so only a presenter that isn't the venue is kept - the same // rule DICE and Clyx apply to the promoter field they get handed. -const getPresentedByNote = (description, cinema) => { - const notes = description - .split("\n") - .map((line) => getPresenterNote(line)) - .filter(Boolean); - if (notes.length === 0) return undefined; - - const [note] = notes; - const presenter = note.replace(/^presented by\s+/i, ""); +const getPresentedByNote = ({ description, host }, cinema) => { const venueNames = [cinema.name, ...(cinema.alternativeNames || [])].filter( Boolean, ); - const isVenueItself = venueNames.some( - (name) => normalizeVenueName(name) === normalizeVenueName(presenter), - ); + const isVenueItself = (presenter) => + venueNames.some( + (name) => normalizeVenueName(name) === normalizeVenueName(presenter), + ); + + const [note] = description + .split("\n") + .map((line) => getPresenterNote(line)) + .filter(Boolean); + if (note) { + const presenter = note.replace(/^presented (?:in \w+ with|by)\s+/i, ""); + return isVenueItself(presenter) ? undefined : note; + } - return isVenueItself ? undefined : note; + return host && !isVenueItself(host) ? `Presented by ${host}` : undefined; }; function extractEventDetails(html) { @@ -68,6 +75,7 @@ function extractEventDetails(html) { return { title, venueName, + host: getText($(".hosted-by-control__name-text").first()) || undefined, dates: parseEventDates(dateText, $("script").text()), description, coordinates: parseVenueCoordinates($), @@ -103,7 +111,7 @@ function convertOutsavvyEvent(event, cinema) { performances: event.dates.map((date) => createPerformance({ date, - notesList: [getPresentedByNote(event.description, cinema)], + notesList: [getPresentedByNote(event, cinema)], url: event.url, accessibility: createAccessibility(event.title, {}, event.description), format: createFormat(event.title, {}, event.description), diff --git a/sources/outsavvy.com/tests/__snapshots__/index.test.js.snap b/sources/outsavvy.com/tests/__snapshots__/index.test.js.snap index f5150b0d4..45bbee17e 100644 --- a/sources/outsavvy.com/tests/__snapshots__/index.test.js.snap +++ b/sources/outsavvy.com/tests/__snapshots__/index.test.js.snap @@ -16,7 +16,7 @@ exports[`OutSavvy The Divine retrieve and find events 1`] = ` }, "bookingUrl": "https://outsavvy.com/event/39626/screening-of-juana-la-lorca", "format": {}, - "notes": "", + "notes": "Presented by ROMANCERO BOOKS", "occasions": { "qAndA": { "guests": [ @@ -44,7 +44,7 @@ exports[`OutSavvy The Divine retrieve and find events 1`] = ` "accessibility": {}, "bookingUrl": "https://outsavvy.com/event/40809/camp-x-camp-classics-presents-vegas-in-space-afterparty-at-the-divine", "format": {}, - "notes": "", + "notes": "Presented by CAMP CLASSICS", "occasions": {}, "status": {}, "time": 1795674600000, @@ -66,7 +66,7 @@ exports[`OutSavvy The Divine retrieve and find events 1`] = ` "accessibility": {}, "bookingUrl": "https://outsavvy.com/event/40810/camp-classics-presents-a-divas-christmas-carol-at-the-divine", "format": {}, - "notes": "", + "notes": "Presented by CAMP CLASSICS", "occasions": {}, "status": {}, "time": 1797966000000, @@ -115,7 +115,7 @@ exports[`OutSavvy UCL East Community Cinema retrieve and find events 1`] = ` "accessibility": {}, "bookingUrl": "https://outsavvy.com/event/40685/the-films-of-annette-kennerley", "format": {}, - "notes": "", + "notes": "Presented by Spectra, Queer Non Fiction Films at UCL East", "occasions": { "qAndA": true, }, diff --git a/sources/outsavvy.com/tests/find-events.test.js b/sources/outsavvy.com/tests/find-events.test.js index 1650e58b3..51a1d74f8 100644 --- a/sources/outsavvy.com/tests/find-events.test.js +++ b/sources/outsavvy.com/tests/find-events.test.js @@ -63,6 +63,21 @@ const divine = { geo: { lat: 51.55190978261229, lon: -0.07543040185169653 }, }; +// An event crediting its organiser only in the "Hosted By" panel +const hostedUrl = "https://outsavvy.com/event/41392/secretary-2002-screening"; +const hostedPage = fs.readFileSync( + path.join(__dirname, "fixtures", "hosted-by.html"), + "utf8", +); + +// As held in cinemas/instagram.com-rhizomatic.ldn/attributes.js +const rhizomatic = { + name: "Rhizomatic!", + alternativeNames: ["Rhizomatic CIC"], + address: "1st Floor, Algha Works, 83 Smeed Road, London, E3 2NR, UK", + geo: { lat: 51.53826174740846, lon: -0.02342173129653179 }, +}; + describe("findEvents", () => { it("takes the start time from the booking widget when the header has none", async () => { readJSON.mockImplementation(() => ({ moviePages: { [url]: page } })); @@ -101,6 +116,33 @@ describe("findEvents", () => { expect(events[0].performances[0].notes).toBe("Presented by CAMP CLASSICS"); }); + it("notes the organiser the page is hosted by", async () => { + readJSON.mockImplementation(() => ({ + moviePages: { [hostedUrl]: hostedPage }, + })); + + const events = await findEvents(rhizomatic); + + expect(events).toHaveLength(1); + expect(events[0].performances[0].notes).toBe( + "Presented by Fashion Film Club", + ); + }); + + it("leaves out a host that is the venue itself", async () => { + readJSON.mockImplementation(() => ({ + moviePages: { [hostedUrl]: hostedPage }, + })); + + const events = await findEvents({ + ...rhizomatic, + alternativeNames: ["Fashion Film Club"], + }); + + expect(events).toHaveLength(1); + expect(events[0].performances[0].notes).toBe(""); + }); + // A venue promoting its own night tells a reader nothing by repeating itself it("leaves out a credit naming the venue itself", async () => { readJSON.mockImplementation(() => ({ diff --git a/sources/outsavvy.com/tests/fixtures/hosted-by.html b/sources/outsavvy.com/tests/fixtures/hosted-by.html new file mode 100644 index 000000000..d160b8c9c --- /dev/null +++ b/sources/outsavvy.com/tests/fixtures/hosted-by.html @@ -0,0 +1,68 @@ + + + +
+
+

Secretary 2002 Screening

+
+ + + + Rhizomatic!
+ 83 Smeed Road,  + London,  + E3 2NR + + (view map) + +
+
+
+ +
+ Wednesday 25th November 2026 at 6:00 PM +
+
+
+
+
+

Fashion film club! Come down the rabbit hole with us with a screening of 2002's Secretary 

Stepping into a slightly diffrent energy from our previous screenings, this month we will be watching Sectary, a visually stunning film with some amazing and sexy fashion moments that any miu miu or Prada enthusiast will enjoy. 

Synopsis: Drected by Steven Shainbergh from a screenplay  by Erin Cressida Wilson, Starring Maggie Gylenhall and James Spader the film explores the intense relationship between a dominant lawyer and his submissive ecretary,

Fashion film club is created by Rosie Okotcha, spotlighting the costumes and creative impact of some of your fave films!

Each evening begins with a gathering hour where guests can mingle, grab a drink, and connect before the film starts.

Event Details:

Doors: 6:00 PM
Film begins: 7:00PM

Post film quizz!

You are encouraged to dress inspired by the film 

Come solo or bring a friend <3

Complimentary pop corn <3

+
+
+
+ + + + Event location map + + + +
+
+
+
+
+ + Fashion Film Club + + + + + + + +
+
+
+
+
+ + +