Greasy Fork is available in English.

WhatsApp group events to ICS

Searches for messages containing a date(s) and a hyperlink in the currently open whatsapp chat (or rather, in messages that have been scrolled past), and lets you download them as an .ics for import into your favourite calendar app.

// ==UserScript==
// @name         WhatsApp group events to ICS
// @license      Unlicense
// @namespace    http://tampermonkey.net/
// @version      2024-04-19
// @description  Searches for messages containing a date(s) and a hyperlink in the currently open whatsapp chat (or rather, in messages that have been scrolled past), and lets you download them as an .ics for import into your favourite calendar app.
// @author       You
// @match        http://web.whatsapp.com/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // 23-27 July 2024
    // 11 July to 25 August 2024(
    // 20 May to 19 June
    // 9-13 September
    // 19 July to 14 August 2024
    // 6-12 October 2024
    // 17-21 September
    //
    // "Mohamed Eladly
    // +20 114 558 3074
    // 209 kB
    // Open call for volunteers from European and non European countries to join one month summer volunteering project in Bulgaria  from 8 July - 8 August 2024 (fully funded)
    //
    //
    // All details and how to apply are available on our website from the following link
    // https://www.opportunit4u.com/2024/04/next-chapter-esc-teams-volunteering-in-bulgaria.html
    //
    // Best wishes
    // 2:11 pm"

    function scrape()
    {
        let events = {};    // We use a dictionary because it means that events encountred multiple times will not be duplicated.

        // Divs that are probably chat bubbles
        let bubbles = Array.from(document.getElementsByTagName('div'))
        .filter(e => e.hasAttribute('data-pre-plain-text'));

        // Go through them
        for (let i = 0; i < bubbles.length; i++)
        {
            let div = bubbles[i];

            let url   = undefined;
            let start = new Date();
            let end   = new Date();

            // Find dates
            const names = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
            let matches = div.innerText.matchAll(/(\d\d?)(st|nd|rd|th)?\s?(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)?\s?(-|to)?\s?(\d\d?)?(st|nd|rd|th)? (Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)/g);
            let match   = matches.next().value;

            if (!match) continue;

            // Parse dates
            start.setDate(parseInt(match[1]));
            end  .setDate(parseInt(match[5] ?? start.getDate() + 1));

            end  .setMonth(names.indexOf(match[7].toLowerCase().slice(0,3)))
            start.setMonth(match[3] ? names.indexOf(match[3].toLowerCase().slice(0,3)) : end.getMonth())

            // Parse link
            let link = (div.innerText.match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/g) ?? [])[0];

            events[link] = {start: start, end: end};
        }

        return events;
    }

    function toICSDate(d)
    {
        let t = "";
        t += d.getUTCFullYear().toString();
        t += ("0" + d.getUTCMonth().toString()).slice(-2);
        t += ("0" + d.getUTCDate().toString()).slice(-2);
        t += "T000000Z";
        return t;
    }

    function makeICS(events)
    {
        let text = (
            "BEGIN:VCALENDAR\n" +
            "VERSION:2.0\n" +
            "PRODID:-//hacksw/handcal//NONSGML v1.0//EN\n"
        );

        for (let [url, date] of Object.entries(events))
        {
            text += "BEGIN:VEVENT\n";
            text += `SUMMARY:${url.split('/').at(-1)}\n`;
            text += `URL:${url}\n`; // https://en.wikipedia.org/wiki/ICalendar
            text += `DTSTART:${toICSDate(date.start)}\n`;
            text += `DTEND:${toICSDate(date.end)}\n`;
            text += "END:VEVENT\n";
        }

        text += "END:VCALENDAR\n";
        return text;
    }

    // Create button
    let b = document.createElement('button');
    b.style.position="absolute";
    b.style.right="20px";
    b.style.top="50%";
    b.style.zIndex=10000;

    b.style.background="lightgreen";
    b.style.padding="40px"

    document.body.appendChild(b);

    // Call scraper
    document._scraped = scrape();

    setInterval(() => {
        document._scraped = Object.assign({}, document._scraped, scrape());     // Merge newly scraped events with existing ones
        b.innerText = `Scrape ${Object.entries(document._scraped).length} events`;
    }, 200);

    b.onclick = () => {
        // https://stackoverflow.com/a/48968694/6130358

        let blob = new Blob([makeICS(document._scraped)]);

        const a = document.createElement('a');
        document.body.appendChild(a);
        const url = window.URL.createObjectURL(blob);
        a.href = url;
        a.download = 'events.ics';
        a.click();
        setTimeout(() => {
            window.URL.revokeObjectURL(url);
            document.body.removeChild(a);
        }, 0);
    };
})();