@mxfb/tools 0.3.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/TODO.md +1 -0
- package/agnostic/@publisher/index.d.ts +47 -0
- package/agnostic/@publisher/index.js +361 -0
- package/agnostic/elo/index.d.ts +23 -0
- package/agnostic/elo/index.js +28 -0
- package/agnostic/elo/index.test.d.ts +1 -0
- package/agnostic/elo/index.test.js +67 -0
- package/agnostic/index.d.ts +5 -4
- package/agnostic/index.js +5 -0
- package/agnostic/poetry/generate-rhymes/index.d.ts +1 -0
- package/agnostic/poetry/generate-rhymes/index.js +125 -0
- package/agnostic/poetry/index.d.ts +1 -0
- package/agnostic/poetry/index.js +1 -0
- package/agnostic/scrapers/@meteociel/index.d.ts +36 -0
- package/agnostic/scrapers/@meteociel/index.js +248 -0
- package/agnostic/scrapers/@meteociel/index.test.d.ts +1 -0
- package/agnostic/scrapers/@meteociel/index.test.js +43 -0
- package/agnostic/scrapers/@meteociel/locations/index.d.ts +11 -0
- package/agnostic/scrapers/@meteociel/locations/index.js +10 -0
- package/agnostic/scrapers/@meteociel/types.d.ts +1 -0
- package/agnostic/scrapers/@meteociel/types.js +1 -0
- package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.d.ts +22 -0
- package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.js +124 -0
- package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.test.d.ts +1 -0
- package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.test.js +107 -0
- package/agnostic/scrapers/@tumblr/blogs/index.d.ts +8 -0
- package/agnostic/scrapers/@tumblr/blogs/index.js +23 -0
- package/agnostic/scrapers/@tumblr/index.d.ts +73 -0
- package/agnostic/scrapers/@tumblr/index.js +180 -0
- package/agnostic/scrapers/@tumblr/index.test.d.ts +1 -0
- package/agnostic/scrapers/@tumblr/index.test.js +319 -0
- package/agnostic/scrapers/@tumblr/types.d.ts +238 -0
- package/agnostic/scrapers/@tumblr/types.js +38 -0
- package/agnostic/scrapers/@tumblr/utils.d.ts +12 -0
- package/agnostic/scrapers/@tumblr/utils.js +407 -0
- package/agnostic/scrapers/index.d.ts +2 -0
- package/agnostic/scrapers/index.js +2 -0
- package/agnostic/weather/index.d.ts +1 -0
- package/agnostic/weather/index.js +1 -0
- package/agnostic/weather/types.d.ts +181 -0
- package/agnostic/weather/types.js +8 -0
- package/components/index.d.ts +1 -1
- package/components/index.js +1 -0
- package/components/public-classnames.d.ts +5 -0
- package/components/public-classnames.js +5 -0
- package/components/utils/index.d.ts +1 -0
- package/components/utils/index.js +12 -0
- package/components/utils/types.d.ts +3 -0
- package/components/utils/types.js +1 -0
- package/index.d.ts +3 -3
- package/index.js +3 -0
- package/node/headless-browser/index.d.ts +37 -0
- package/node/headless-browser/index.js +126 -0
- package/node/headless-browser/types.d.ts +47 -0
- package/node/headless-browser/types.js +5 -0
- package/node/index.d.ts +1 -2
- package/node/index.js +1 -0
- package/node/index.test.d.ts +1 -0
- package/node/index.test.js +6 -0
- package/node/scrapers/@tumblr/index.d.ts +61 -0
- package/node/scrapers/@tumblr/index.js +182 -0
- package/node/scrapers/@tumblr/index.test.d.ts +1 -0
- package/node/scrapers/@tumblr/index.test.js +185 -0
- package/node/scrapers/@tumblr/types.d.ts +91 -0
- package/node/scrapers/@tumblr/types.js +1 -0
- package/node/scrapers/@tumblr/utils.d.ts +23 -0
- package/node/scrapers/@tumblr/utils.js +328 -0
- package/node/scrapers/index.d.ts +1 -0
- package/node/scrapers/index.js +1 -0
- package/package.json +211 -14
- package/agnostic/_WIP_tennis/index.d.ts +0 -2
- package/agnostic/_WIP_tennis/index.js +0 -62
- package/agnostic/arrays/index.d.ts +0 -11
- package/agnostic/arrays/is-array-of/index.d.ts +0 -5
- package/agnostic/arrays/is-array-of/index.js +0 -23
- package/agnostic/async/index.d.ts +0 -8
- package/agnostic/async/index.js +0 -20
- package/agnostic/async/timeout-call/index.d.ts +0 -1
- package/agnostic/async/timeout-call/index.js +0 -6
- package/agnostic/async/transitions/index.d.ts +0 -41
- package/agnostic/async/transitions/index.js +0 -7
- package/agnostic/async/wait/index.d.ts +0 -1
- package/agnostic/async/wait/index.js +0 -6
- package/agnostic/booleans/index.d.ts +0 -8
- package/agnostic/booleans/index.js +0 -17
- package/agnostic/booleans/is-falsy/index.d.ts +0 -7
- package/agnostic/booleans/is-falsy/index.js +0 -11
- package/agnostic/css/bem/BEM.d.ts +0 -29
- package/agnostic/css/bem/getNamesArr.d.ts +0 -2
- package/agnostic/css/bem/index.d.ts +0 -7
- package/agnostic/css/bem/index.js +0 -9
- package/agnostic/css/generate-nice-color/index.d.ts +0 -2
- package/agnostic/css/generate-nice-color/index.js +0 -8
- package/agnostic/css/index.d.ts +0 -14
- package/agnostic/css/index.js +0 -177
- package/agnostic/css/is-valid-css-class-name/index.d.ts +0 -2
- package/agnostic/css/is-valid-css-class-name/index.js +0 -8
- package/agnostic/css/styles-set/index.d.ts +0 -37
- package/agnostic/errors/index.d.ts +0 -30
- package/agnostic/errors/index.js +0 -6
- package/agnostic/html/get-node-ancestors/index.d.ts +0 -1
- package/agnostic/html/get-node-ancestors/index.js +0 -6
- package/agnostic/html/get-position-inside-parent/index.d.ts +0 -1
- package/agnostic/html/get-position-inside-parent/index.js +0 -6
- package/agnostic/html/index.d.ts +0 -17
- package/agnostic/html/index.js +0 -39
- package/agnostic/html/insert-node/index.d.ts +0 -2
- package/agnostic/html/insert-node/index.js +0 -6
- package/agnostic/html/placeholders/index.d.ts +0 -9
- package/agnostic/html/placeholders/index.js +0 -6
- package/agnostic/html/sanitize/index.d.ts +0 -24
- package/agnostic/html/sanitize/index.js +0 -9
- package/agnostic/html/selector-to-element/index.d.ts +0 -1
- package/agnostic/html/selector-to-element/index.js +0 -9
- package/agnostic/html/string-to-nodes/index.d.ts +0 -7
- package/agnostic/html/string-to-nodes/index.js +0 -10
- package/agnostic/misc/assert/index.d.ts +0 -5
- package/agnostic/misc/assert/index.js +0 -6
- package/agnostic/misc/cast/index.d.ts +0 -9
- package/agnostic/misc/cast/index.js +0 -8
- package/agnostic/misc/crossenv/index.d.ts +0 -13
- package/agnostic/misc/crossenv/index.js +0 -6
- package/agnostic/misc/elo/index.d.ts +0 -31
- package/agnostic/misc/elo/index.js +0 -6
- package/agnostic/misc/get-current-downlink/index.d.ts +0 -1
- package/agnostic/misc/get-current-downlink/index.js +0 -6
- package/agnostic/misc/index.d.ts +0 -6
- package/agnostic/misc/index.js +0 -16
- package/agnostic/misc/is-constructor-function/index.d.ts +0 -2
- package/agnostic/misc/is-constructor-function/index.js +0 -6
- package/agnostic/misc/is-nullish/index.d.ts +0 -4
- package/agnostic/misc/is-nullish/index.js +0 -10
- package/agnostic/misc/logs/index.d.ts +0 -55
- package/agnostic/misc/logs/index.js +0 -6
- package/agnostic/misc/random/index.d.ts +0 -10
- package/agnostic/misc/random/index.js +0 -6
- package/agnostic/misc/tennis/index.d.ts +0 -60
- package/agnostic/misc/tennis/index.js +0 -6
- package/agnostic/numbers/absolute-modulo/index.d.ts +0 -1
- package/agnostic/numbers/absolute-modulo/index.js +0 -6
- package/agnostic/numbers/clamp/index.d.ts +0 -1
- package/agnostic/numbers/clamp/index.js +0 -6
- package/agnostic/numbers/index.d.ts +0 -13
- package/agnostic/numbers/index.js +0 -30
- package/agnostic/numbers/interpolate/index.d.ts +0 -1
- package/agnostic/numbers/interpolate/index.js +0 -6
- package/agnostic/numbers/responsive-harmonics/index.d.ts +0 -10
- package/agnostic/numbers/responsive-harmonics/index.js +0 -9
- package/agnostic/numbers/round/index.d.ts +0 -1
- package/agnostic/numbers/round/index.js +0 -6
- package/agnostic/objects/enums/index.d.ts +0 -4
- package/agnostic/objects/enums/index.js +0 -7
- package/agnostic/objects/enums/is-in-enum/index.d.ts +0 -1
- package/agnostic/objects/enums/is-in-enum/index.js +0 -6
- package/agnostic/objects/flatten-getters/index.d.ts +0 -1
- package/agnostic/objects/flatten-getters/index.js +0 -9
- package/agnostic/objects/index.d.ts +0 -12
- package/agnostic/objects/index.js +0 -32
- package/agnostic/objects/is-object/index.d.ts +0 -1
- package/agnostic/objects/is-object/index.js +0 -6
- package/agnostic/objects/is-record/index.d.ts +0 -1
- package/agnostic/objects/is-record/index.js +0 -6
- package/agnostic/objects/record-format/index.d.ts +0 -11
- package/agnostic/objects/record-format/index.js +0 -16
- package/agnostic/objects/validation/index.d.ts +0 -3
- package/agnostic/objects/validation/index.js +0 -6
- package/agnostic/optim/index.d.ts +0 -7
- package/agnostic/optim/index.js +0 -18
- package/agnostic/optim/memoize/index.d.ts +0 -3
- package/agnostic/optim/memoize/index.js +0 -6
- package/agnostic/optim/throttle-debounce/index.d.ts +0 -30
- package/agnostic/optim/throttle-debounce/index.js +0 -8
- package/agnostic/optim/throttle-debounce/types.d.ts +0 -2
- package/agnostic/regexps/index.d.ts +0 -23
- package/agnostic/regexps/index.js +0 -89
- package/agnostic/strings/char-codes/index.d.ts +0 -17
- package/agnostic/strings/char-codes/index.js +0 -6
- package/agnostic/strings/index.d.ts +0 -14
- package/agnostic/strings/index.js +0 -32
- package/agnostic/strings/matches/index.d.ts +0 -3
- package/agnostic/strings/matches/index.js +0 -10
- package/agnostic/strings/normalize-indent/index.d.ts +0 -1
- package/agnostic/strings/normalize-indent/index.js +0 -6
- package/agnostic/strings/replace-all/index.d.ts +0 -1
- package/agnostic/strings/replace-all/index.js +0 -6
- package/agnostic/strings/to-alphanum/index.d.ts +0 -1
- package/agnostic/strings/to-alphanum/index.js +0 -6
- package/chunks/chunk-32IRF4OP.js +0 -9
- package/chunks/chunk-34U4HX4V.js +0 -73
- package/chunks/chunk-4GDNHUCZ.js +0 -162
- package/chunks/chunk-4QQBKBYH.js +0 -59
- package/chunks/chunk-4VC5PT5T.js +0 -13
- package/chunks/chunk-5LNUCDQ6.js +0 -132
- package/chunks/chunk-7AWTHZLY.js +0 -12
- package/chunks/chunk-7DBNMU6N.js +0 -14
- package/chunks/chunk-COVPTTAD.js +0 -10
- package/chunks/chunk-DNIOWD7K.js +0 -8
- package/chunks/chunk-DQNJQBC6.js +0 -10
- package/chunks/chunk-E6MSDKON.js +0 -13
- package/chunks/chunk-EDVNAV3G.js +0 -72
- package/chunks/chunk-EW46EXFB.js +0 -8
- package/chunks/chunk-FPEW3A27.js +0 -14
- package/chunks/chunk-GJOU3UAL.js +0 -36
- package/chunks/chunk-HC6ZOHCS.js +0 -14
- package/chunks/chunk-HGCG2J77.js +0 -122
- package/chunks/chunk-IFD7GSGK.js +0 -130
- package/chunks/chunk-JQXNEJAP.js +0 -18
- package/chunks/chunk-JWBDZPQG.js +0 -18
- package/chunks/chunk-K37RALUX.js +0 -8
- package/chunks/chunk-KIONYWA7.js +0 -168
- package/chunks/chunk-KQT5CTGF.js +0 -21
- package/chunks/chunk-LEXQ6E6L.js +0 -31
- package/chunks/chunk-LQFKUNVQ.js +0 -19
- package/chunks/chunk-MF56TTC5.js +0 -15
- package/chunks/chunk-MT74WIQR.js +0 -41
- package/chunks/chunk-NRITRUZW.js +0 -10
- package/chunks/chunk-NRJQKVQV.js +0 -31
- package/chunks/chunk-OSAXBA7G.js +0 -10
- package/chunks/chunk-PO3V3I57.js +0 -23
- package/chunks/chunk-QXAJXTXV.js +0 -19
- package/chunks/chunk-RCO57B6F.js +0 -9
- package/chunks/chunk-SQZGZ3VT.js +0 -15
- package/chunks/chunk-TTZHPIH7.js +0 -237
- package/chunks/chunk-UYTXXUU7.js +0 -54
- package/chunks/chunk-VTPRO4NJ.js +0 -10
- package/chunks/chunk-VYW4IADX.js +0 -35
- package/chunks/chunk-VZDUZTW6.js +0 -113
- package/chunks/chunk-W5A2TON3.js +0 -10
- package/chunks/chunk-W7YBGJ4H.js +0 -28
- package/chunks/chunk-WLL3FNVL.js +0 -40
- package/chunks/chunk-WOAYU6LB.js +0 -13
- package/chunks/chunk-XA4HVHJ4.js +0 -18
- package/chunks/chunk-XN3PZIVL.js +0 -38
- package/chunks/chunk-Y3OZHZUF.js +0 -237
- package/chunks/chunk-YDIBNEGA.js +0 -9
- package/chunks/chunk-ZC5RZBNY.js +0 -33
- package/components/Comp/index.d.ts +0 -1
- package/node/files/index.d.ts +0 -9
- package/node/files/index.js +0 -21
- package/node/files/is-in-directory/index.d.ts +0 -1
- package/node/files/is-in-directory/index.js +0 -6
- package/node/files/read-write/index.d.ts +0 -9
- package/node/files/read-write/index.js +0 -6
- package/node/files/subpaths/index.d.ts +0 -34
- package/node/files/subpaths/index.js +0 -7
- package/node/image/index.d.ts +0 -9
- package/node/image/index.js +0 -74
- package/node/mxfb-cli-config/index.d.ts +0 -19
- package/node/mxfb-cli-config/index.js +0 -113
- package/node/process/index.d.ts +0 -8
- package/node/process/index.js +0 -20
- package/node/process/on-exit/index.d.ts +0 -5
- package/node/process/on-exit/index.js +0 -14
- package/shared/errors/index.d.ts +0 -138
- package/shared/index.d.ts +0 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/* Linting is suspended on this file — dormant project, left as it was found. See
|
|
2
|
+
« Two files opted out of linting » in README.md before removing this. */
|
|
3
|
+
/* eslint-disable
|
|
4
|
+
@typescript-eslint/no-unsafe-type-assertion,
|
|
5
|
+
@typescript-eslint/no-unused-vars,
|
|
6
|
+
no-console
|
|
7
|
+
*/
|
|
8
|
+
import { shuffle } from '@design-edito/tools/agnostic/arrays/shuffle/index.js';
|
|
9
|
+
/* * * * * * * * * * * * * * * * *
|
|
10
|
+
*
|
|
11
|
+
* Rhyme Generator
|
|
12
|
+
*
|
|
13
|
+
* * * * * * * * * * * * * * * * */
|
|
14
|
+
function getRandomSum(n) {
|
|
15
|
+
if (n <= 0 || !Number.isInteger(n))
|
|
16
|
+
throw new Error('n doit être un entier positif');
|
|
17
|
+
const result = [];
|
|
18
|
+
let remaining = n;
|
|
19
|
+
while (remaining > 0) {
|
|
20
|
+
const value = Math.floor((1 - (Math.random() * Math.random())) * remaining) + 1; // [1, remaining]
|
|
21
|
+
result.push(value);
|
|
22
|
+
remaining -= value;
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
const getWordsByFeet = (feet, wordsRecord) => Object
|
|
27
|
+
.entries(wordsRecord)
|
|
28
|
+
.filter(([, value]) => value === feet)
|
|
29
|
+
.map(([key]) => key);
|
|
30
|
+
const getWords = (feet, wordsRecord, coeff) => {
|
|
31
|
+
const foundWordsThatLong = shuffle(getWordsByFeet(feet, wordsRecord))[0];
|
|
32
|
+
if (feet === 1) {
|
|
33
|
+
if (foundWordsThatLong === undefined)
|
|
34
|
+
throw new Error('need at least 1 word that is 1 foot long');
|
|
35
|
+
return [foundWordsThatLong];
|
|
36
|
+
}
|
|
37
|
+
if (foundWordsThatLong !== undefined
|
|
38
|
+
&& Math.random() < coeff)
|
|
39
|
+
return [foundWordsThatLong];
|
|
40
|
+
let randomSubParts = [];
|
|
41
|
+
while (randomSubParts.length < 2) {
|
|
42
|
+
randomSubParts = getRandomSum(feet);
|
|
43
|
+
}
|
|
44
|
+
return randomSubParts.map(ft => getWords(ft, wordsRecord, coeff)).flat();
|
|
45
|
+
};
|
|
46
|
+
const flattenRhymeRecord = (rhymRec) => {
|
|
47
|
+
return Object.entries(rhymRec).reduce((acc, [rhyme, words]) => {
|
|
48
|
+
return { ...acc, ...words };
|
|
49
|
+
}, {});
|
|
50
|
+
};
|
|
51
|
+
const getRhyme = (suffix, feet, rhymeRecord, coeff) => {
|
|
52
|
+
const foundRhymeThatLong = shuffle(getWordsByFeet(feet, rhymeRecord[suffix] ?? {}))[0];
|
|
53
|
+
if (feet === 1) {
|
|
54
|
+
if (foundRhymeThatLong === undefined)
|
|
55
|
+
return [`${suffix} *`]; //throw new Error('need at least 1 word that is 1 foot long')
|
|
56
|
+
return [foundRhymeThatLong];
|
|
57
|
+
}
|
|
58
|
+
if (foundRhymeThatLong !== undefined
|
|
59
|
+
&& Math.random() < coeff)
|
|
60
|
+
return [foundRhymeThatLong];
|
|
61
|
+
let randomSubParts = [];
|
|
62
|
+
while (randomSubParts.length < 2) {
|
|
63
|
+
randomSubParts = getRandomSum(feet);
|
|
64
|
+
}
|
|
65
|
+
return randomSubParts.map((ft, pos) => {
|
|
66
|
+
if (pos === randomSubParts.length - 1)
|
|
67
|
+
return getRhyme(suffix, ft, rhymeRecord, coeff);
|
|
68
|
+
return shuffle(getWords(ft, flattenRhymeRecord(rhymeRecord), coeff));
|
|
69
|
+
}).flat();
|
|
70
|
+
};
|
|
71
|
+
const getPoem = function (stanza, rhymeRecord, coeff) {
|
|
72
|
+
return stanza.map(({ length, rhyme }) => length === 0 ? '' : getRhyme(rhyme, length, rhymeRecord, coeff).join(' ')).join('\n');
|
|
73
|
+
};
|
|
74
|
+
/* * * * * * * * * * * * * * * * *
|
|
75
|
+
*
|
|
76
|
+
* TESTS - Rhyme Generator
|
|
77
|
+
*
|
|
78
|
+
* * * * * * * * * * * * * * * * */
|
|
79
|
+
const rhymeRecord = {
|
|
80
|
+
'-a': {
|
|
81
|
+
'a': 1,
|
|
82
|
+
},
|
|
83
|
+
'-è': {
|
|
84
|
+
'eh': 1,
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const _rhymeRecordTypecheck = rhymeRecord;
|
|
88
|
+
const COEFF = .85;
|
|
89
|
+
console.log(`
|
|
90
|
+
|
|
91
|
+
${getPoem([{
|
|
92
|
+
length: 4,
|
|
93
|
+
rhyme: '-a'
|
|
94
|
+
}, {
|
|
95
|
+
length: 4,
|
|
96
|
+
rhyme: '-a'
|
|
97
|
+
}, {
|
|
98
|
+
length: 3,
|
|
99
|
+
rhyme: '-è'
|
|
100
|
+
}, {
|
|
101
|
+
length: 0
|
|
102
|
+
}, {
|
|
103
|
+
length: 2,
|
|
104
|
+
rhyme: '-a'
|
|
105
|
+
}, {
|
|
106
|
+
length: 0
|
|
107
|
+
}, {
|
|
108
|
+
length: 2,
|
|
109
|
+
rhyme: '-è'
|
|
110
|
+
}, {
|
|
111
|
+
length: 4,
|
|
112
|
+
rhyme: '-è'
|
|
113
|
+
}, {
|
|
114
|
+
length: 0
|
|
115
|
+
}, {
|
|
116
|
+
length: 4,
|
|
117
|
+
rhyme: '-a'
|
|
118
|
+
}, {
|
|
119
|
+
length: 3,
|
|
120
|
+
rhyme: '-è'
|
|
121
|
+
}, {
|
|
122
|
+
length: 1542,
|
|
123
|
+
rhyme: '-a'
|
|
124
|
+
}], rhymeRecord, COEFF)}
|
|
125
|
+
`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * as generateRhymes from './generate-rhymes/index.js'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * as generateRhymes from './generate-rhymes/index.js'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type CityName } from './locations/index.js';
|
|
2
|
+
import type { WeatherForecast } from '../../weather/types.js';
|
|
3
|
+
import type { ForecastMode } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Builds the Meteociel forecast page URL for a given city and forecast mode.
|
|
6
|
+
*
|
|
7
|
+
* @param cityName - The city identifier used to resolve Meteociel location data.
|
|
8
|
+
* @param mode - The forecast mode to use when building the URL.
|
|
9
|
+
* @returns The fully qualified Meteociel URL for the requested city and mode.
|
|
10
|
+
* @throws If the provided forecast mode is not supported.
|
|
11
|
+
*/
|
|
12
|
+
export declare function getCityAndModeUrl(cityName: CityName, mode: ForecastMode): string;
|
|
13
|
+
/**
|
|
14
|
+
* Fetches a remote HTML page and decodes it using ISO-8859-1 encoding.
|
|
15
|
+
*
|
|
16
|
+
* @param url - The URL of the page to fetch.
|
|
17
|
+
* @returns The decoded HTML content of the fetched page.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fetchPage(url: string): Promise<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Retrieves and scrapes weather forecast data for a given city and forecast mode.
|
|
22
|
+
*
|
|
23
|
+
* @param cityName - The city for which to retrieve weather forecasts.
|
|
24
|
+
* @param mode - The forecast mode determining the scraping strategy.
|
|
25
|
+
* @returns An array of weather forecasts enriched with city location metadata.
|
|
26
|
+
* @throws If the forecast mode is unknown or unsupported.
|
|
27
|
+
*/
|
|
28
|
+
export declare function scrap(cityName: CityName, mode: ForecastMode): Promise<WeatherForecast[]>;
|
|
29
|
+
/**
|
|
30
|
+
* Scrapes 3-day GFS weather simple forecasts from a Meteociel HTML page.
|
|
31
|
+
*
|
|
32
|
+
* @param pageContent - The raw HTML content of the Meteociel forecast page.
|
|
33
|
+
* @returns An array of weather forecasts parsed from the page content.
|
|
34
|
+
* @throws If required data such as issue time or geolocation cannot be extracted.
|
|
35
|
+
*/
|
|
36
|
+
export declare function scrapSimpleGfsPrevisions(pageContent: string): WeatherForecast[];
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { JSDOM } from 'jsdom';
|
|
2
|
+
import { citiesData } from './locations/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Builds the Meteociel forecast page URL for a given city and forecast mode.
|
|
5
|
+
*
|
|
6
|
+
* @param cityName - The city identifier used to resolve Meteociel location data.
|
|
7
|
+
* @param mode - The forecast mode to use when building the URL.
|
|
8
|
+
* @returns The fully qualified Meteociel URL for the requested city and mode.
|
|
9
|
+
* @throws If the provided forecast mode is not supported.
|
|
10
|
+
*/
|
|
11
|
+
export function getCityAndModeUrl(cityName, mode) {
|
|
12
|
+
// `ForecastMode` has a single member today; this guard and the `default` branch of
|
|
13
|
+
// `scrap` are what will catch a second one.
|
|
14
|
+
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
|
15
|
+
const modeUrlChunk = mode === 'simple-gfs' ? 'previsions' : null;
|
|
16
|
+
if (modeUrlChunk === null)
|
|
17
|
+
throw new Error('invalid mode');
|
|
18
|
+
return 'https://www.meteociel.fr/'
|
|
19
|
+
+ `${modeUrlChunk}/`
|
|
20
|
+
+ `${citiesData[cityName].id}/`
|
|
21
|
+
+ `${citiesData[cityName].urlSuffix}.htm`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Fetches a remote HTML page and decodes it using ISO-8859-1 encoding.
|
|
25
|
+
*
|
|
26
|
+
* @param url - The URL of the page to fetch.
|
|
27
|
+
* @returns The decoded HTML content of the fetched page.
|
|
28
|
+
*/
|
|
29
|
+
export async function fetchPage(url) {
|
|
30
|
+
const fetched = await fetch(url);
|
|
31
|
+
const buffer = await fetched.arrayBuffer();
|
|
32
|
+
const html = new TextDecoder('iso-8859-1').decode(buffer);
|
|
33
|
+
return html;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Retrieves and scrapes weather forecast data for a given city and forecast mode.
|
|
37
|
+
*
|
|
38
|
+
* @param cityName - The city for which to retrieve weather forecasts.
|
|
39
|
+
* @param mode - The forecast mode determining the scraping strategy.
|
|
40
|
+
* @returns An array of weather forecasts enriched with city location metadata.
|
|
41
|
+
* @throws If the forecast mode is unknown or unsupported.
|
|
42
|
+
*/
|
|
43
|
+
export async function scrap(cityName, mode) {
|
|
44
|
+
const url = getCityAndModeUrl(cityName, mode);
|
|
45
|
+
const fetched = await fetchPage(url);
|
|
46
|
+
switch (mode) {
|
|
47
|
+
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
|
48
|
+
case 'simple-gfs': {
|
|
49
|
+
const scraped = scrapSimpleGfsPrevisions(fetched);
|
|
50
|
+
return scraped.map(i => ({
|
|
51
|
+
...i,
|
|
52
|
+
location: {
|
|
53
|
+
...i.location,
|
|
54
|
+
name: cityName
|
|
55
|
+
}
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
/* eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check */
|
|
59
|
+
default: throw new Error('unknown-mode');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Scrapes 3-day GFS weather simple forecasts from a Meteociel HTML page.
|
|
64
|
+
*
|
|
65
|
+
* @param pageContent - The raw HTML content of the Meteociel forecast page.
|
|
66
|
+
* @returns An array of weather forecasts parsed from the page content.
|
|
67
|
+
* @throws If required data such as issue time or geolocation cannot be extracted.
|
|
68
|
+
*/
|
|
69
|
+
export function scrapSimpleGfsPrevisions(pageContent) {
|
|
70
|
+
const dom = new JSDOM(pageContent);
|
|
71
|
+
const { document } = dom.window;
|
|
72
|
+
// Storing collection time
|
|
73
|
+
const COLLECTED_ON = new Date();
|
|
74
|
+
// Extracting relevant DOM nodes
|
|
75
|
+
const h3Parent = Array
|
|
76
|
+
.from(document.querySelectorAll('h3'))
|
|
77
|
+
.find(n => n.textContent.includes('Prévisions météo à 3 jours'))
|
|
78
|
+
?.parentElement;
|
|
79
|
+
const issueTimeNode = Array
|
|
80
|
+
.from(h3Parent?.childNodes ?? [])
|
|
81
|
+
.find(n => n.textContent?.includes('Réactualisé à') === true);
|
|
82
|
+
const dataTableNode = Array
|
|
83
|
+
.from(document.querySelectorAll('tbody'))
|
|
84
|
+
.find(n => {
|
|
85
|
+
const trChldCnt = n.querySelectorAll('tr').length;
|
|
86
|
+
return trChldCnt > 25 && trChldCnt < 35;
|
|
87
|
+
});
|
|
88
|
+
const forecastTableRows = Array
|
|
89
|
+
.from(dataTableNode?.querySelectorAll('tr') ?? [])
|
|
90
|
+
.slice(2);
|
|
91
|
+
const locationNode = Array.from(Array
|
|
92
|
+
.from(document.querySelectorAll('p'))
|
|
93
|
+
.find(n => n.textContent.includes('sont expérimentales et déterminées à partir des données brutes de GFS, un modèle numérique américain'))
|
|
94
|
+
?.childNodes ?? []).find(n => n.textContent?.startsWith('Localisation de') === true);
|
|
95
|
+
// Extracting issue time
|
|
96
|
+
const issuedOnRaw = issueTimeNode?.textContent?.match(/\b([0-9]{1,2}):([0-5][0-9])\b/v)?.[0];
|
|
97
|
+
if (issuedOnRaw === undefined)
|
|
98
|
+
throw new Error('no-refresh-time-found');
|
|
99
|
+
// The regex above matched, so there are exactly two colon-separated groups.
|
|
100
|
+
/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */
|
|
101
|
+
const [issuedOnH, issuedOnM] = issuedOnRaw.split(':');
|
|
102
|
+
const issuedOnYesteredayMaybe = new Date();
|
|
103
|
+
issuedOnYesteredayMaybe.setDate(new Date().getDate() - 1);
|
|
104
|
+
issuedOnYesteredayMaybe.setHours(parseInt(issuedOnH));
|
|
105
|
+
issuedOnYesteredayMaybe.setMinutes(parseInt(issuedOnM));
|
|
106
|
+
issuedOnYesteredayMaybe.setSeconds(0);
|
|
107
|
+
issuedOnYesteredayMaybe.setMilliseconds(0);
|
|
108
|
+
const issuedOnYesteredayMaybeNowDiff = Math.abs(new Date().getTime() - issuedOnYesteredayMaybe.getTime());
|
|
109
|
+
const issuedOnTodayMaybe = new Date();
|
|
110
|
+
issuedOnTodayMaybe.setHours(parseInt(issuedOnH));
|
|
111
|
+
issuedOnTodayMaybe.setMinutes(parseInt(issuedOnM));
|
|
112
|
+
issuedOnTodayMaybe.setSeconds(0);
|
|
113
|
+
issuedOnTodayMaybe.setMilliseconds(0);
|
|
114
|
+
const issuedOnTodayMaybeNowDiff = Math.abs(new Date().getTime() - issuedOnTodayMaybe.getTime());
|
|
115
|
+
const ISSUED_ON = issuedOnYesteredayMaybeNowDiff > issuedOnTodayMaybeNowDiff
|
|
116
|
+
? issuedOnTodayMaybe
|
|
117
|
+
: issuedOnYesteredayMaybe;
|
|
118
|
+
// Extracting location
|
|
119
|
+
function parseLocation(input) {
|
|
120
|
+
const regex = /Latitude\s+([NS])\s+(\d+)°(\d+)'\s+\/\s+Longitude\s+([EW])\s+(\d+)°(\d+)'\s+\/\s+Altitude\s*:\s*(\d+)\s*m/v;
|
|
121
|
+
const match = input.match(regex);
|
|
122
|
+
if (match === null)
|
|
123
|
+
throw new Error('Format de coordonnées invalide');
|
|
124
|
+
const [, latDir, latDeg, latMin, lonDir, lonDeg, lonMin, altStr] = match;
|
|
125
|
+
let lat = Number(latDeg) + Number(latMin) / 60;
|
|
126
|
+
let lon = Number(lonDeg) + Number(lonMin) / 60;
|
|
127
|
+
if (latDir === 'S')
|
|
128
|
+
lat = -1 * lat;
|
|
129
|
+
if (lonDir === 'W')
|
|
130
|
+
lon = -1 * lon;
|
|
131
|
+
const alt = Number(altStr);
|
|
132
|
+
return {
|
|
133
|
+
latitudeDeg: lat,
|
|
134
|
+
longitudeDeg: lon,
|
|
135
|
+
elevationM: alt
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
let LOCATION;
|
|
139
|
+
try {
|
|
140
|
+
LOCATION = parseLocation(locationNode?.textContent ?? '');
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
throw new Error('no-geolocation-found', { cause: err });
|
|
144
|
+
}
|
|
145
|
+
// Accessing data table rows
|
|
146
|
+
let currentDateLabel = null;
|
|
147
|
+
const currentHour = new Date();
|
|
148
|
+
currentHour.setMinutes(0);
|
|
149
|
+
currentHour.setSeconds(0);
|
|
150
|
+
currentHour.setMilliseconds(0);
|
|
151
|
+
const returned = forecastTableRows.map(row => {
|
|
152
|
+
const cells = Array.from(row.querySelectorAll('td'));
|
|
153
|
+
// Filter out irrelevant lines
|
|
154
|
+
if (cells.length < 10 || cells.length > 11)
|
|
155
|
+
return undefined;
|
|
156
|
+
// Extract cells
|
|
157
|
+
let dateCell;
|
|
158
|
+
const otherCells = [];
|
|
159
|
+
if (cells.length === 11) {
|
|
160
|
+
dateCell = cells[0];
|
|
161
|
+
otherCells.push(...cells.slice(1));
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
otherCells.push(...cells);
|
|
165
|
+
}
|
|
166
|
+
// Extract current date
|
|
167
|
+
if (dateCell !== undefined) {
|
|
168
|
+
currentDateLabel = dateCell.textContent;
|
|
169
|
+
}
|
|
170
|
+
const [hourCell, tempCell, , windDirCell, windSpeedCell, gustSpeedCell, rainAmountCell, hygrometryCell, airPressureCell, weatherCell] = otherCells;
|
|
171
|
+
const hour = hourCell?.textContent ?? null;
|
|
172
|
+
const temp = tempCell?.textContent ?? null;
|
|
173
|
+
const windDir = windDirCell?.querySelector('img')?.getAttribute('title') ?? null;
|
|
174
|
+
const [, windDirAngle = null] = windDir?.split(' : ') ?? [];
|
|
175
|
+
const windSpeed = windSpeedCell?.textContent ?? null;
|
|
176
|
+
const gustSpeed = gustSpeedCell?.textContent ?? null;
|
|
177
|
+
const rainAmount = rainAmountCell?.textContent === '--' ? null : (rainAmountCell?.textContent) ?? null;
|
|
178
|
+
const hygrometry = hygrometryCell?.textContent ?? null;
|
|
179
|
+
const airPressure = airPressureCell?.textContent ?? null;
|
|
180
|
+
const weather = weatherCell?.querySelector('img')?.getAttribute('title') ?? null;
|
|
181
|
+
// Infer the actual date
|
|
182
|
+
const rawDay = currentDateLabel;
|
|
183
|
+
const foundDate = rawDay?.slice(3) ?? '0';
|
|
184
|
+
const pMonthFullDate = new Date(currentHour);
|
|
185
|
+
pMonthFullDate.setMonth(currentHour.getMonth() - 1);
|
|
186
|
+
pMonthFullDate.setDate(parseInt(foundDate));
|
|
187
|
+
pMonthFullDate.setHours(parseInt(hour ?? '00'));
|
|
188
|
+
const curMonthFullDate = new Date(currentHour);
|
|
189
|
+
curMonthFullDate.setDate(parseInt(foundDate));
|
|
190
|
+
curMonthFullDate.setHours(parseInt(hour ?? '00'));
|
|
191
|
+
const nMonthFullDate = new Date(currentHour);
|
|
192
|
+
nMonthFullDate.setMonth(currentHour.getMonth() + 1);
|
|
193
|
+
nMonthFullDate.setDate(parseInt(foundDate));
|
|
194
|
+
nMonthFullDate.setHours(parseInt(hour ?? '00'));
|
|
195
|
+
const eligibleDateTimesDiff = [
|
|
196
|
+
Math.abs(currentHour.getTime() - pMonthFullDate.getTime()),
|
|
197
|
+
Math.abs(currentHour.getTime() - curMonthFullDate.getTime()),
|
|
198
|
+
Math.abs(currentHour.getTime() - nMonthFullDate.getTime())
|
|
199
|
+
];
|
|
200
|
+
const minDateDiff = Math.min(...eligibleDateTimesDiff);
|
|
201
|
+
const indexOfMinDateDiff = eligibleDateTimesDiff.indexOf(minDateDiff);
|
|
202
|
+
const date = indexOfMinDateDiff === 0
|
|
203
|
+
? pMonthFullDate
|
|
204
|
+
: (indexOfMinDateDiff === 1
|
|
205
|
+
? curMonthFullDate
|
|
206
|
+
: (indexOfMinDateDiff === 2
|
|
207
|
+
? nMonthFullDate
|
|
208
|
+
: currentHour));
|
|
209
|
+
const validityTo = new Date(date);
|
|
210
|
+
validityTo.setHours(date.getHours() + 3);
|
|
211
|
+
const VALIDITY = {
|
|
212
|
+
from: date,
|
|
213
|
+
to: validityTo
|
|
214
|
+
};
|
|
215
|
+
// Convert scraped data to WeatherForecast compliant types
|
|
216
|
+
const TEMPERATURE = { tempC: parseFloat(temp?.replace(' °C', '') ?? '') };
|
|
217
|
+
const HUMIDITY = { relativePercent: parseFloat(hygrometry?.replace(' %', '') ?? '') };
|
|
218
|
+
const WIND = {
|
|
219
|
+
speedMs: parseFloat(windSpeed ?? ''),
|
|
220
|
+
directionDeg: parseFloat(windDirAngle?.replace(' °', '') ?? ''),
|
|
221
|
+
gustMs: parseFloat(gustSpeed ?? '')
|
|
222
|
+
};
|
|
223
|
+
const PRESSURE = { stationLevelHpa: parseFloat(airPressure?.replace(' hPa', '') ?? '') };
|
|
224
|
+
const SOURCE = {
|
|
225
|
+
provider: 'Météociel',
|
|
226
|
+
type: 'model',
|
|
227
|
+
model: 'GFS'
|
|
228
|
+
};
|
|
229
|
+
// Return
|
|
230
|
+
const returned = {
|
|
231
|
+
issuedOn: ISSUED_ON,
|
|
232
|
+
location: LOCATION,
|
|
233
|
+
validity: VALIDITY,
|
|
234
|
+
temperature: TEMPERATURE,
|
|
235
|
+
humidity: HUMIDITY,
|
|
236
|
+
wind: WIND,
|
|
237
|
+
pressure: PRESSURE,
|
|
238
|
+
precipitationMmh: parseFloat(rainAmount ?? ''),
|
|
239
|
+
metadata: {
|
|
240
|
+
source: SOURCE,
|
|
241
|
+
description: weather ?? 'unknown',
|
|
242
|
+
collectedOn: COLLECTED_ON
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
return returned;
|
|
246
|
+
}).filter(e => e !== undefined);
|
|
247
|
+
return returned;
|
|
248
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { getCityAndModeUrl, fetchPage, scrap, scrapSimpleGfsPrevisions } from './index.js';
|
|
3
|
+
import { citiesData } from './locations/index.js';
|
|
4
|
+
describe('scrapers/@meteociel/weather/getCityAndModeUrl', () => {
|
|
5
|
+
it('builds a valid Meteociel URL for a supported mode', () => {
|
|
6
|
+
const city = Object.keys(citiesData)[0];
|
|
7
|
+
const url = getCityAndModeUrl(city, 'simple-gfs');
|
|
8
|
+
expect(url).toContain('https://www.meteociel.fr/');
|
|
9
|
+
expect(url).toContain('previsions');
|
|
10
|
+
});
|
|
11
|
+
it('throws on unsupported mode', () => {
|
|
12
|
+
const city = Object.keys(citiesData)[0];
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
14
|
+
expect(() => getCityAndModeUrl(city, 'unknown')).toThrow();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
describe('scrapers/@meteociel/weather/fetchPage', () => {
|
|
18
|
+
const originalFetch = global.fetch;
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
global.fetch = vi.fn(async () => ({
|
|
21
|
+
arrayBuffer: async () => new TextEncoder().encode('<html>ok</html>').buffer
|
|
22
|
+
}));
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
global.fetch = originalFetch;
|
|
26
|
+
});
|
|
27
|
+
it('fetches and decodes HTML content', async () => {
|
|
28
|
+
const html = await fetchPage('https://example.com');
|
|
29
|
+
expect(html).toContain('<html>');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('scrapers/@meteociel/weather/scrap', () => {
|
|
33
|
+
it('throws on unsupported mode', async () => {
|
|
34
|
+
const city = Object.keys((await import('./locations/index.js')).citiesData)[0];
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
36
|
+
await expect(scrap(city, 'unknown')).rejects.toBeDefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('scrapers/@meteociel/weather/scrapSimpleGfsPrevisions', () => {
|
|
40
|
+
it('throws when page content is invalid', () => {
|
|
41
|
+
expect(() => scrapSimpleGfsPrevisions('<html></html>')).toThrow();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type ForecastMode = 'simple-gfs';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TumblrBlogParser } from '../../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reads the theme `delabienbonnemusique.tumblr.com` has been frozen on since 2019.
|
|
4
|
+
*
|
|
5
|
+
* Every post is one track: a line of text in `.copy`, an embed in `.media` when the video
|
|
6
|
+
* still exists, and sometimes a `.copy.source` link. That line is kept whole and never
|
|
7
|
+
* taken apart — fifteen years of posts were not written to one format, and an artist
|
|
8
|
+
* guessed from a dash is worse than the line itself. Class names are what this reads,
|
|
9
|
+
* which is what a per-blog parser is for; the generic pass is the one that may not rely on
|
|
10
|
+
* them.
|
|
11
|
+
*
|
|
12
|
+
* What the years make fragile is the source: a YouTube or Dailymotion embed that may well
|
|
13
|
+
* be dead by now, a Spotify link, a Grooveshark one on the oldest posts. Every URL found
|
|
14
|
+
* in the embed or the body is kept, in the order it appeared, plus the embed's own title —
|
|
15
|
+
* which on a deleted video is the last trace of what it was.
|
|
16
|
+
*
|
|
17
|
+
* The date is the only one this blog has: the theme writes it in the `title` of its
|
|
18
|
+
* `.date` link, and the sitemap's `lastmod` is a 2023 bulk re-touch that says nothing
|
|
19
|
+
* about publication. It carries no timezone, so it is read as UTC and the label itself is
|
|
20
|
+
* kept in `detail.publishedLabel` for whoever needs to argue about the hour.
|
|
21
|
+
*/
|
|
22
|
+
export declare const parser: TumblrBlogParser;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const MONTH_INDEXES_BY_FRENCH_NAME = new Map([
|
|
2
|
+
['janvier', 0],
|
|
3
|
+
['février', 1],
|
|
4
|
+
['mars', 2],
|
|
5
|
+
['avril', 3],
|
|
6
|
+
['mai', 4],
|
|
7
|
+
['juin', 5],
|
|
8
|
+
['juillet', 6],
|
|
9
|
+
['août', 7],
|
|
10
|
+
['septembre', 8],
|
|
11
|
+
['octobre', 9],
|
|
12
|
+
['novembre', 10],
|
|
13
|
+
['décembre', 11]
|
|
14
|
+
]);
|
|
15
|
+
// `Jeu. janvier 31, 2019 @ 5:39 pm` — the weekday is dropped, it states nothing the rest
|
|
16
|
+
// does not.
|
|
17
|
+
const DATE_LABEL_PATTERN = /^\p{L}+\.?\s+(\p{L}+)\s+(\d{1,2}),\s*(\d{4})\s*@\s*(\d{1,2}):(\d{2})\s*(am|pm)$/iv;
|
|
18
|
+
const POST_TYPE_CLASS_PREFIX = 'type-';
|
|
19
|
+
// Where a source can sit: the embed, or a link in the body. Everything else a post holds
|
|
20
|
+
// is the theme's own — share buttons, the permalink, the short link.
|
|
21
|
+
const SOURCE_AREA_SELECTOR = '.media, .copy';
|
|
22
|
+
/**
|
|
23
|
+
* Reads the theme `delabienbonnemusique.tumblr.com` has been frozen on since 2019.
|
|
24
|
+
*
|
|
25
|
+
* Every post is one track: a line of text in `.copy`, an embed in `.media` when the video
|
|
26
|
+
* still exists, and sometimes a `.copy.source` link. That line is kept whole and never
|
|
27
|
+
* taken apart — fifteen years of posts were not written to one format, and an artist
|
|
28
|
+
* guessed from a dash is worse than the line itself. Class names are what this reads,
|
|
29
|
+
* which is what a per-blog parser is for; the generic pass is the one that may not rely on
|
|
30
|
+
* them.
|
|
31
|
+
*
|
|
32
|
+
* What the years make fragile is the source: a YouTube or Dailymotion embed that may well
|
|
33
|
+
* be dead by now, a Spotify link, a Grooveshark one on the oldest posts. Every URL found
|
|
34
|
+
* in the embed or the body is kept, in the order it appeared, plus the embed's own title —
|
|
35
|
+
* which on a deleted video is the last trace of what it was.
|
|
36
|
+
*
|
|
37
|
+
* The date is the only one this blog has: the theme writes it in the `title` of its
|
|
38
|
+
* `.date` link, and the sitemap's `lastmod` is a 2023 bulk re-touch that says nothing
|
|
39
|
+
* about publication. It carries no timezone, so it is read as UTC and the label itself is
|
|
40
|
+
* kept in `detail.publishedLabel` for whoever needs to argue about the hour.
|
|
41
|
+
*/
|
|
42
|
+
export const parser = {
|
|
43
|
+
parsePostDom: postElement => {
|
|
44
|
+
const copyElement = postElement.querySelector('.copy:not(.source)');
|
|
45
|
+
const publishedLabel = readDateLabel(postElement);
|
|
46
|
+
const iframe = postElement.querySelector('.media iframe');
|
|
47
|
+
const sourceUrls = readSourceUrls(postElement);
|
|
48
|
+
const detail = {
|
|
49
|
+
postType: readPostType(postElement),
|
|
50
|
+
publishedLabel,
|
|
51
|
+
sourceUrl: sourceUrls[0] ?? null,
|
|
52
|
+
sourceUrls,
|
|
53
|
+
embedTitle: iframe?.getAttribute('title') ?? null
|
|
54
|
+
};
|
|
55
|
+
const parsed = {
|
|
56
|
+
detail,
|
|
57
|
+
title: toTextOrNull(copyElement?.textContent ?? null),
|
|
58
|
+
contentHtml: copyElement?.innerHTML.trim() ?? null,
|
|
59
|
+
publishedOn: toDateOrNull(publishedLabel),
|
|
60
|
+
media: readMedia(iframe)
|
|
61
|
+
};
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
function toTextOrNull(raw) {
|
|
66
|
+
if (raw === null)
|
|
67
|
+
return null;
|
|
68
|
+
const normalized = raw.replace(/\s+/gv, ' ').trim();
|
|
69
|
+
return normalized === '' ? null : normalized;
|
|
70
|
+
}
|
|
71
|
+
function readSourceUrls(postElement) {
|
|
72
|
+
const urls = [];
|
|
73
|
+
for (const area of postElement.querySelectorAll(SOURCE_AREA_SELECTOR)) {
|
|
74
|
+
for (const node of area.querySelectorAll('iframe[src], a[href], embed[src]')) {
|
|
75
|
+
const url = node.getAttribute('src') ?? node.getAttribute('href');
|
|
76
|
+
if (url === null || url === '' || urls.includes(url))
|
|
77
|
+
continue;
|
|
78
|
+
urls.push(url);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return urls;
|
|
82
|
+
}
|
|
83
|
+
function readDateLabel(postElement) {
|
|
84
|
+
return toTextOrNull(postElement.querySelector('.date a')?.getAttribute('title') ?? null);
|
|
85
|
+
}
|
|
86
|
+
function readPostType(postElement) {
|
|
87
|
+
for (const className of Array.from(postElement.classList)) {
|
|
88
|
+
if (className.startsWith(POST_TYPE_CLASS_PREFIX)) {
|
|
89
|
+
return className.slice(POST_TYPE_CLASS_PREFIX.length);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
function toDateOrNull(publishedLabel) {
|
|
95
|
+
if (publishedLabel === null)
|
|
96
|
+
return null;
|
|
97
|
+
const parts = DATE_LABEL_PATTERN.exec(publishedLabel);
|
|
98
|
+
if (parts === null)
|
|
99
|
+
return null;
|
|
100
|
+
const [, monthName, day, year, hour, minute, meridiem] = parts;
|
|
101
|
+
const monthIndex = MONTH_INDEXES_BY_FRENCH_NAME.get(monthName?.toLowerCase() ?? '');
|
|
102
|
+
if (monthIndex === undefined)
|
|
103
|
+
return null;
|
|
104
|
+
const hours = Number(hour) % 12 + (meridiem?.toLowerCase() === 'pm' ? 12 : 0);
|
|
105
|
+
return new Date(Date.UTC(Number(year), monthIndex, Number(day), hours, Number(minute)));
|
|
106
|
+
}
|
|
107
|
+
function readMedia(iframe) {
|
|
108
|
+
const url = iframe?.getAttribute('src') ?? null;
|
|
109
|
+
if (url === null)
|
|
110
|
+
return [];
|
|
111
|
+
return [{
|
|
112
|
+
kind: 'iframe',
|
|
113
|
+
url,
|
|
114
|
+
widthPx: toIntegerOrNull(iframe?.getAttribute('width') ?? null),
|
|
115
|
+
heightPx: toIntegerOrNull(iframe?.getAttribute('height') ?? null),
|
|
116
|
+
alternativeText: iframe?.getAttribute('title') ?? null
|
|
117
|
+
}];
|
|
118
|
+
}
|
|
119
|
+
function toIntegerOrNull(raw) {
|
|
120
|
+
if (raw === null)
|
|
121
|
+
return null;
|
|
122
|
+
const parsed = Number.parseInt(raw, 10);
|
|
123
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
124
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|