-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(clock): scrap thebulletin.org and retrieve the Doomsday clock
- Loading branch information
1 parent
9b5e1ad
commit 09c5ffc
Showing
10 changed files
with
281 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" | ||
|
@@ -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" | ||
|
@@ -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" | ||
|
@@ -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" | ||
|
@@ -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" | ||
|
@@ -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== | ||
|