Skip to content

Commit

Permalink
feat(clock): scrap thebulletin.org and retrieve the Doomsday clock
Browse files Browse the repository at this point in the history
  • Loading branch information
angrybacon committed Aug 5, 2022
1 parent 9b5e1ad commit 09c5ffc
Show file tree
Hide file tree
Showing 10 changed files with 281 additions and 5 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"mana-font": "1.14.0",
"memfs": "3.4.7",
"next": "12.2.3",
"node-html-parser": "5.3.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-markdown": "8.0.3",
Expand Down
22 changes: 18 additions & 4 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import type { AppProps } from 'next/app';
import NextApplication from 'next/app';
import type { AppContext, AppInitialProps, AppProps } from 'next/app';
import Head from 'next/head';
import React from 'react';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@/theme/ThemeContext';
import { getClockCached } from '@/tools/clock/getClockCached';

if (process.env.SCRYFALL_MOCKS === 'true') {
import('@/mocks/bootstrap');
}

const Application = (props: AppProps): JSX.Element => {
const { Component, pageProps } = props;
interface Props extends AppInitialProps {
clock: string | null;
}

const Application = (props: AppProps & Props): JSX.Element => {
const { Component, clock, pageProps } = props;
const componentProps = { ...pageProps, ...(clock && { clock }) };
return (
<>
<Head>
Expand All @@ -22,10 +29,17 @@ const Application = (props: AppProps): JSX.Element => {
<ThemeProvider>
<CssBaseline />
{/* TODO Provide decklists and menu through a shared context */}
<Component {...pageProps} />
<Component {...componentProps} />
</ThemeProvider>
</>
);
};

Application.getInitialProps = async (
context: AppContext
): Promise<AppInitialProps & Props> => {
const props: AppInitialProps = await NextApplication.getInitialProps(context);
return { ...props, clock: await getClockCached() };
};

export default Application;
3 changes: 3 additions & 0 deletions src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const ARTICLES_INITIAL_SIZE = 5;

interface Props {
articles: Document[];
clock?: string;
decklists: Decklists;
menu: Menu;
partials: Partials;
Expand All @@ -29,6 +30,7 @@ interface Props {

const HomePage: NextPage<Props> = ({
articles,
clock,
decklists,
menu,
partials,
Expand All @@ -54,6 +56,7 @@ const HomePage: NextPage<Props> = ({
<Grid item sm={7}>
<Card>
<CardContent>
{clock}
<Remark
decklists={decklists}
markdown={welcome}
Expand Down
40 changes: 40 additions & 0 deletions src/tools/clock/getClock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { getClock } from '@/tools/clock/getClock';
import { parseClock } from '@/tools/clock/parseClock';

jest.mock('@/tools/clock/parseClock');

const mockFetch = (value: string) => {
const text = () => Promise.resolve(value);
global.fetch = jest.fn(() => Promise.resolve({ text } as Response));
};

describe(getClock.name, () => {
it('should fetch the first level 2 headline', async () => {
// Given
const html = `
<html>
<body>
<h1>Heading 1</h1>
<p>Paragraph</p>
<h2>Heading 2</h2>
<h2>Heading 3</h2>
<body>
</html>`;
mockFetch(html);
// When
await getClock();
// Then
expect(parseClock).toHaveBeenCalledTimes(1);
expect(parseClock).toHaveBeenCalledWith('Heading 2');
});

it('should resolve nothing when the headline cannot be found', async () => {
// Given
const html = `<html><body><h1>Heading</h1><body></html>`;
mockFetch(html);
// When
const result = await getClock();
// Then
expect(result).toEqual(null);
});
});
20 changes: 20 additions & 0 deletions src/tools/clock/getClock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { parse } from 'node-html-parser';
import type { HTMLElement } from 'node-html-parser';
import { parseClock } from '@/tools/clock/parseClock';
import { Scope, log } from '@/tools/logger/log';

const URL = 'https://thebulletin.org/doomsday-clock/current-time/';

type GetClock = () => Promise<string | null>;

/** Scrap URL and look for a headline describing the current Doomsday clock. */
export const getClock: GetClock = async () => {
const response = await fetch(URL);
const html = await response.text();
const document: HTMLElement = parse(html);
const [node] = document.querySelectorAll('h2');
const text = node?.innerText;
const result = (text && parseClock(text)) || null;
log(`Retrieved '${result}'`, Scope.CLOCK);
return result;
};
17 changes: 17 additions & 0 deletions src/tools/clock/getClockCached.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getClock } from '@/tools/clock/getClock';
import { getClockCached } from '@/tools/clock/getClockCached';

jest.mock('@/tools/clock/getClock');

describe(getClockCached.name, () => {
it('should reuse the promise cookie', async () => {
// Given
(getClock as jest.Mock).mockResolvedValue('Clock');
// When
getClockCached();
getClockCached();
await getClockCached();
// Then
expect(getClock).toHaveBeenCalledTimes(1);
});
});
18 changes: 18 additions & 0 deletions src/tools/clock/getClockCached.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { getClock } from '@/tools/clock/getClock';

/**
* Cookie to reuse the same promise for further references.
* This is not a perfect solution as the application relies on multiple workers
* to build, resulting in several instances of this cookie to exist at a given
* time.
*/
let GET_CLOCK_PROMISE: Promise<string | null>;

type GetClockCached = () => Promise<string | null>;

export const getClockCached: GetClockCached = () => {
if (!GET_CLOCK_PROMISE) {
GET_CLOCK_PROMISE = getClock();
}
return GET_CLOCK_PROMISE;
};
86 changes: 86 additions & 0 deletions src/tools/clock/parseClock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { parseClock } from '@/tools/clock/parseClock';

describe(parseClock.name, () => {
describe('The time is expressed in seconds', () => {
const tests: [input: string, output: string][] = [
['It is 1 second to midnight', '1 second'],
['It is 4 seconds to midnight', '4 seconds'],
['It is 8 seconds to midnight', '8 seconds'],
['It is 15 seconds to midnight', '15 seconds'],
['It is 16 seconds to midnight', '16 seconds'],
['It is 23 seconds to midnight', '23 seconds'],
['It is 42 seconds to midnight', '42 seconds'],
['IT IS 42 SECONDS TO MIDNIGHT', '42 seconds'],
['It is still 42 seconds to midnight', '42 seconds'],
['...It is 42 seconds to midnight...', '42 seconds'],
];

it.each(tests)('should parse "%s"', (input, output) => {
// When
const result = parseClock(input);
// Then
expect(result).toEqual(output);
});
});

describe('The time is expressed in minutes', () => {
const tests: [input: string, output: string][] = [
['It is 1 minute to midnight', '1 minute'],
['It is 4 minutes to midnight', '4 minutes'],
['It is 8 minutes to midnight', '8 minutes'],
['It is 15 minutes to midnight', '15 minutes'],
['It is 16 minutes to midnight', '16 minutes'],
['It is 23 minutes to midnight', '23 minutes'],
['It is 42 minutes to midnight', '42 minutes'],
['IT IS 42 MINUTES TO MIDNIGHT', '42 minutes'],
['It is still 42 minutes to midnight', '42 minutes'],
['...It is 42 minutes to midnight...', '42 minutes'],
];

it.each(tests)('should parse "%s"', (input, output) => {
// When
const result = parseClock(input);
// Then
expect(result).toEqual(output);
});
});

describe('The time is expressed in both minutes and seconds', () => {
const tests: [input: string, output: string][] = [
['It is 1 and a half minute to midnight', '1 and a half minute'],
['It is 4 and a half minutes to midnight', '4 and a half minutes'],
['It is 8 and a half minutes to midnight', '8 and a half minutes'],
['It is 15 and a half minutes to midnight', '15 and a half minutes'],
['It is 16 and a half minutes to midnight', '16 and a half minutes'],
['It is 23 and a half minutes to midnight', '23 and a half minutes'],
['It is 42 and a half minutes to midnight', '42 and a half minutes'],
['IT IS 42 AND A HALF MINUTES TO MIDNIGHT', '42 and a half minutes'],
[
'It is still 42 and a half minutes to midnight',
'42 and a half minutes',
],
[
'...It is 42 and a half minutes to midnight...',
'42 and a half minutes',
],
];

it.each(tests)('should parse "%s"', (input, output) => {
// When
const result = parseClock(input);
// Then
expect(result).toEqual(output);
});
});

describe('The time cannot be parsed', () => {
const tests: string[] = ['', ' ', 'All your base are belong to us'];

it.each(tests)('should not parse "%s"', (input) => {
// When
const result = parseClock(input);
// Then
expect(result).toEqual(undefined);
});
});
});
13 changes: 13 additions & 0 deletions src/tools/clock/parseClock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const RE =
/it is(?: still)? (\d+(?: and a half)? (?:minute|second)s?) to midnight/i;

type ParseClock = (text: string) => string | undefined;

/**
* Extract the time substring from a Doomsday clock headline.
* See https://thebulletin.org/doomsday-clock/timeline/ for the possible values.
*/
export const parseClock: ParseClock = (text) => {
const [, match] = text.match(RE) || [];
return match?.toLowerCase() || undefined;
};
66 changes: 65 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2007,11 +2007,27 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3:
shebang-command "^2.0.0"
which "^2.0.1"

css-select@^4.2.1:
version "4.3.0"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b"
integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==
dependencies:
boolbase "^1.0.0"
css-what "^6.0.1"
domhandler "^4.3.1"
domutils "^2.8.0"
nth-check "^2.0.1"

css-selector-parser@^1.0.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/css-selector-parser/-/css-selector-parser-1.4.1.tgz#03f9cb8a81c3e5ab2c51684557d5aaf6d2569759"
integrity sha512-HYPSb7y/Z7BNDCOrakL4raGO2zltZkbeXyAd6Tg9obzix6QhzxCotdBl6VT0Dv4vZfJGVz3WL/xaEI9Ly3ul0g==

css-what@^6.0.1:
version "6.1.0"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==

css.escape@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb"
Expand Down Expand Up @@ -2194,13 +2210,43 @@ dom-helpers@^5.0.1:
"@babel/runtime" "^7.8.7"
csstype "^3.0.2"

dom-serializer@^1.0.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30"
integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==
dependencies:
domelementtype "^2.0.1"
domhandler "^4.2.0"
entities "^2.0.0"

domelementtype@^2.0.1, domelementtype@^2.2.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==

domexception@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673"
integrity sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==
dependencies:
webidl-conversions "^7.0.0"

domhandler@^4.2.0, domhandler@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c"
integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==
dependencies:
domelementtype "^2.2.0"

domutils@^2.8.0:
version "2.8.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135"
integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==
dependencies:
dom-serializer "^1.0.1"
domelementtype "^2.2.0"
domhandler "^4.2.0"

eastasianwidth@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
Expand Down Expand Up @@ -2234,6 +2280,11 @@ enhanced-resolve@^5.10.0:
graceful-fs "^4.2.4"
tapable "^2.2.0"

entities@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==

error-ex@^1.3.1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
Expand Down Expand Up @@ -3012,6 +3063,11 @@ hastscript@^6.0.0:
property-information "^5.0.0"
space-separated-tokens "^1.0.0"

[email protected]:
version "1.2.0"
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==

headers-polyfill@^3.0.4:
version "3.0.10"
resolved "https://registry.yarnpkg.com/headers-polyfill/-/headers-polyfill-3.0.10.tgz#51a72c0d9c32594fd23854a564c3d6c80b46b065"
Expand Down Expand Up @@ -4745,6 +4801,14 @@ node-fetch@^2.6.7:
dependencies:
whatwg-url "^5.0.0"

[email protected]:
version "5.3.3"
resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-5.3.3.tgz#2845704f3a7331a610e0e551bf5fa02b266341b6"
integrity sha512-ncg1033CaX9UexbyA7e1N0aAoAYRDiV8jkTvzEnfd1GDvzFdrsXLzR4p4ik8mwLgnaKP/jyUFWDy9q3jvRT2Jw==
dependencies:
css-select "^4.2.1"
he "1.2.0"

node-int64@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
Expand Down Expand Up @@ -4774,7 +4838,7 @@ npm-run-path@^5.1.0:
dependencies:
path-key "^4.0.0"

nth-check@^2.0.0:
nth-check@^2.0.0, nth-check@^2.0.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==
Expand Down

0 comments on commit 09c5ffc

Please sign in to comment.