@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.
Files changed (255) hide show
  1. package/TODO.md +1 -0
  2. package/agnostic/@publisher/index.d.ts +47 -0
  3. package/agnostic/@publisher/index.js +361 -0
  4. package/agnostic/elo/index.d.ts +23 -0
  5. package/agnostic/elo/index.js +28 -0
  6. package/agnostic/elo/index.test.d.ts +1 -0
  7. package/agnostic/elo/index.test.js +67 -0
  8. package/agnostic/index.d.ts +5 -4
  9. package/agnostic/index.js +5 -0
  10. package/agnostic/poetry/generate-rhymes/index.d.ts +1 -0
  11. package/agnostic/poetry/generate-rhymes/index.js +125 -0
  12. package/agnostic/poetry/index.d.ts +1 -0
  13. package/agnostic/poetry/index.js +1 -0
  14. package/agnostic/scrapers/@meteociel/index.d.ts +36 -0
  15. package/agnostic/scrapers/@meteociel/index.js +248 -0
  16. package/agnostic/scrapers/@meteociel/index.test.d.ts +1 -0
  17. package/agnostic/scrapers/@meteociel/index.test.js +43 -0
  18. package/agnostic/scrapers/@meteociel/locations/index.d.ts +11 -0
  19. package/agnostic/scrapers/@meteociel/locations/index.js +10 -0
  20. package/agnostic/scrapers/@meteociel/types.d.ts +1 -0
  21. package/agnostic/scrapers/@meteociel/types.js +1 -0
  22. package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.d.ts +22 -0
  23. package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.js +124 -0
  24. package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.test.d.ts +1 -0
  25. package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.test.js +107 -0
  26. package/agnostic/scrapers/@tumblr/blogs/index.d.ts +8 -0
  27. package/agnostic/scrapers/@tumblr/blogs/index.js +23 -0
  28. package/agnostic/scrapers/@tumblr/index.d.ts +73 -0
  29. package/agnostic/scrapers/@tumblr/index.js +180 -0
  30. package/agnostic/scrapers/@tumblr/index.test.d.ts +1 -0
  31. package/agnostic/scrapers/@tumblr/index.test.js +319 -0
  32. package/agnostic/scrapers/@tumblr/types.d.ts +238 -0
  33. package/agnostic/scrapers/@tumblr/types.js +38 -0
  34. package/agnostic/scrapers/@tumblr/utils.d.ts +12 -0
  35. package/agnostic/scrapers/@tumblr/utils.js +407 -0
  36. package/agnostic/scrapers/index.d.ts +2 -0
  37. package/agnostic/scrapers/index.js +2 -0
  38. package/agnostic/weather/index.d.ts +1 -0
  39. package/agnostic/weather/index.js +1 -0
  40. package/agnostic/weather/types.d.ts +181 -0
  41. package/agnostic/weather/types.js +8 -0
  42. package/components/index.d.ts +1 -1
  43. package/components/index.js +1 -0
  44. package/components/public-classnames.d.ts +5 -0
  45. package/components/public-classnames.js +5 -0
  46. package/components/utils/index.d.ts +1 -0
  47. package/components/utils/index.js +12 -0
  48. package/components/utils/types.d.ts +3 -0
  49. package/components/utils/types.js +1 -0
  50. package/index.d.ts +3 -3
  51. package/index.js +3 -0
  52. package/node/headless-browser/index.d.ts +37 -0
  53. package/node/headless-browser/index.js +126 -0
  54. package/node/headless-browser/types.d.ts +47 -0
  55. package/node/headless-browser/types.js +5 -0
  56. package/node/index.d.ts +1 -2
  57. package/node/index.js +1 -0
  58. package/node/index.test.d.ts +1 -0
  59. package/node/index.test.js +6 -0
  60. package/node/scrapers/@tumblr/index.d.ts +61 -0
  61. package/node/scrapers/@tumblr/index.js +182 -0
  62. package/node/scrapers/@tumblr/index.test.d.ts +1 -0
  63. package/node/scrapers/@tumblr/index.test.js +185 -0
  64. package/node/scrapers/@tumblr/types.d.ts +91 -0
  65. package/node/scrapers/@tumblr/types.js +1 -0
  66. package/node/scrapers/@tumblr/utils.d.ts +23 -0
  67. package/node/scrapers/@tumblr/utils.js +328 -0
  68. package/node/scrapers/index.d.ts +1 -0
  69. package/node/scrapers/index.js +1 -0
  70. package/package.json +211 -14
  71. package/agnostic/_WIP_tennis/index.d.ts +0 -2
  72. package/agnostic/_WIP_tennis/index.js +0 -62
  73. package/agnostic/arrays/index.d.ts +0 -11
  74. package/agnostic/arrays/is-array-of/index.d.ts +0 -5
  75. package/agnostic/arrays/is-array-of/index.js +0 -23
  76. package/agnostic/async/index.d.ts +0 -8
  77. package/agnostic/async/index.js +0 -20
  78. package/agnostic/async/timeout-call/index.d.ts +0 -1
  79. package/agnostic/async/timeout-call/index.js +0 -6
  80. package/agnostic/async/transitions/index.d.ts +0 -41
  81. package/agnostic/async/transitions/index.js +0 -7
  82. package/agnostic/async/wait/index.d.ts +0 -1
  83. package/agnostic/async/wait/index.js +0 -6
  84. package/agnostic/booleans/index.d.ts +0 -8
  85. package/agnostic/booleans/index.js +0 -17
  86. package/agnostic/booleans/is-falsy/index.d.ts +0 -7
  87. package/agnostic/booleans/is-falsy/index.js +0 -11
  88. package/agnostic/css/bem/BEM.d.ts +0 -29
  89. package/agnostic/css/bem/getNamesArr.d.ts +0 -2
  90. package/agnostic/css/bem/index.d.ts +0 -7
  91. package/agnostic/css/bem/index.js +0 -9
  92. package/agnostic/css/generate-nice-color/index.d.ts +0 -2
  93. package/agnostic/css/generate-nice-color/index.js +0 -8
  94. package/agnostic/css/index.d.ts +0 -14
  95. package/agnostic/css/index.js +0 -177
  96. package/agnostic/css/is-valid-css-class-name/index.d.ts +0 -2
  97. package/agnostic/css/is-valid-css-class-name/index.js +0 -8
  98. package/agnostic/css/styles-set/index.d.ts +0 -37
  99. package/agnostic/errors/index.d.ts +0 -30
  100. package/agnostic/errors/index.js +0 -6
  101. package/agnostic/html/get-node-ancestors/index.d.ts +0 -1
  102. package/agnostic/html/get-node-ancestors/index.js +0 -6
  103. package/agnostic/html/get-position-inside-parent/index.d.ts +0 -1
  104. package/agnostic/html/get-position-inside-parent/index.js +0 -6
  105. package/agnostic/html/index.d.ts +0 -17
  106. package/agnostic/html/index.js +0 -39
  107. package/agnostic/html/insert-node/index.d.ts +0 -2
  108. package/agnostic/html/insert-node/index.js +0 -6
  109. package/agnostic/html/placeholders/index.d.ts +0 -9
  110. package/agnostic/html/placeholders/index.js +0 -6
  111. package/agnostic/html/sanitize/index.d.ts +0 -24
  112. package/agnostic/html/sanitize/index.js +0 -9
  113. package/agnostic/html/selector-to-element/index.d.ts +0 -1
  114. package/agnostic/html/selector-to-element/index.js +0 -9
  115. package/agnostic/html/string-to-nodes/index.d.ts +0 -7
  116. package/agnostic/html/string-to-nodes/index.js +0 -10
  117. package/agnostic/misc/assert/index.d.ts +0 -5
  118. package/agnostic/misc/assert/index.js +0 -6
  119. package/agnostic/misc/cast/index.d.ts +0 -9
  120. package/agnostic/misc/cast/index.js +0 -8
  121. package/agnostic/misc/crossenv/index.d.ts +0 -13
  122. package/agnostic/misc/crossenv/index.js +0 -6
  123. package/agnostic/misc/elo/index.d.ts +0 -31
  124. package/agnostic/misc/elo/index.js +0 -6
  125. package/agnostic/misc/get-current-downlink/index.d.ts +0 -1
  126. package/agnostic/misc/get-current-downlink/index.js +0 -6
  127. package/agnostic/misc/index.d.ts +0 -6
  128. package/agnostic/misc/index.js +0 -16
  129. package/agnostic/misc/is-constructor-function/index.d.ts +0 -2
  130. package/agnostic/misc/is-constructor-function/index.js +0 -6
  131. package/agnostic/misc/is-nullish/index.d.ts +0 -4
  132. package/agnostic/misc/is-nullish/index.js +0 -10
  133. package/agnostic/misc/logs/index.d.ts +0 -55
  134. package/agnostic/misc/logs/index.js +0 -6
  135. package/agnostic/misc/random/index.d.ts +0 -10
  136. package/agnostic/misc/random/index.js +0 -6
  137. package/agnostic/misc/tennis/index.d.ts +0 -60
  138. package/agnostic/misc/tennis/index.js +0 -6
  139. package/agnostic/numbers/absolute-modulo/index.d.ts +0 -1
  140. package/agnostic/numbers/absolute-modulo/index.js +0 -6
  141. package/agnostic/numbers/clamp/index.d.ts +0 -1
  142. package/agnostic/numbers/clamp/index.js +0 -6
  143. package/agnostic/numbers/index.d.ts +0 -13
  144. package/agnostic/numbers/index.js +0 -30
  145. package/agnostic/numbers/interpolate/index.d.ts +0 -1
  146. package/agnostic/numbers/interpolate/index.js +0 -6
  147. package/agnostic/numbers/responsive-harmonics/index.d.ts +0 -10
  148. package/agnostic/numbers/responsive-harmonics/index.js +0 -9
  149. package/agnostic/numbers/round/index.d.ts +0 -1
  150. package/agnostic/numbers/round/index.js +0 -6
  151. package/agnostic/objects/enums/index.d.ts +0 -4
  152. package/agnostic/objects/enums/index.js +0 -7
  153. package/agnostic/objects/enums/is-in-enum/index.d.ts +0 -1
  154. package/agnostic/objects/enums/is-in-enum/index.js +0 -6
  155. package/agnostic/objects/flatten-getters/index.d.ts +0 -1
  156. package/agnostic/objects/flatten-getters/index.js +0 -9
  157. package/agnostic/objects/index.d.ts +0 -12
  158. package/agnostic/objects/index.js +0 -32
  159. package/agnostic/objects/is-object/index.d.ts +0 -1
  160. package/agnostic/objects/is-object/index.js +0 -6
  161. package/agnostic/objects/is-record/index.d.ts +0 -1
  162. package/agnostic/objects/is-record/index.js +0 -6
  163. package/agnostic/objects/record-format/index.d.ts +0 -11
  164. package/agnostic/objects/record-format/index.js +0 -16
  165. package/agnostic/objects/validation/index.d.ts +0 -3
  166. package/agnostic/objects/validation/index.js +0 -6
  167. package/agnostic/optim/index.d.ts +0 -7
  168. package/agnostic/optim/index.js +0 -18
  169. package/agnostic/optim/memoize/index.d.ts +0 -3
  170. package/agnostic/optim/memoize/index.js +0 -6
  171. package/agnostic/optim/throttle-debounce/index.d.ts +0 -30
  172. package/agnostic/optim/throttle-debounce/index.js +0 -8
  173. package/agnostic/optim/throttle-debounce/types.d.ts +0 -2
  174. package/agnostic/regexps/index.d.ts +0 -23
  175. package/agnostic/regexps/index.js +0 -89
  176. package/agnostic/strings/char-codes/index.d.ts +0 -17
  177. package/agnostic/strings/char-codes/index.js +0 -6
  178. package/agnostic/strings/index.d.ts +0 -14
  179. package/agnostic/strings/index.js +0 -32
  180. package/agnostic/strings/matches/index.d.ts +0 -3
  181. package/agnostic/strings/matches/index.js +0 -10
  182. package/agnostic/strings/normalize-indent/index.d.ts +0 -1
  183. package/agnostic/strings/normalize-indent/index.js +0 -6
  184. package/agnostic/strings/replace-all/index.d.ts +0 -1
  185. package/agnostic/strings/replace-all/index.js +0 -6
  186. package/agnostic/strings/to-alphanum/index.d.ts +0 -1
  187. package/agnostic/strings/to-alphanum/index.js +0 -6
  188. package/chunks/chunk-32IRF4OP.js +0 -9
  189. package/chunks/chunk-34U4HX4V.js +0 -73
  190. package/chunks/chunk-4GDNHUCZ.js +0 -162
  191. package/chunks/chunk-4QQBKBYH.js +0 -59
  192. package/chunks/chunk-4VC5PT5T.js +0 -13
  193. package/chunks/chunk-5LNUCDQ6.js +0 -132
  194. package/chunks/chunk-7AWTHZLY.js +0 -12
  195. package/chunks/chunk-7DBNMU6N.js +0 -14
  196. package/chunks/chunk-COVPTTAD.js +0 -10
  197. package/chunks/chunk-DNIOWD7K.js +0 -8
  198. package/chunks/chunk-DQNJQBC6.js +0 -10
  199. package/chunks/chunk-E6MSDKON.js +0 -13
  200. package/chunks/chunk-EDVNAV3G.js +0 -72
  201. package/chunks/chunk-EW46EXFB.js +0 -8
  202. package/chunks/chunk-FPEW3A27.js +0 -14
  203. package/chunks/chunk-GJOU3UAL.js +0 -36
  204. package/chunks/chunk-HC6ZOHCS.js +0 -14
  205. package/chunks/chunk-HGCG2J77.js +0 -122
  206. package/chunks/chunk-IFD7GSGK.js +0 -130
  207. package/chunks/chunk-JQXNEJAP.js +0 -18
  208. package/chunks/chunk-JWBDZPQG.js +0 -18
  209. package/chunks/chunk-K37RALUX.js +0 -8
  210. package/chunks/chunk-KIONYWA7.js +0 -168
  211. package/chunks/chunk-KQT5CTGF.js +0 -21
  212. package/chunks/chunk-LEXQ6E6L.js +0 -31
  213. package/chunks/chunk-LQFKUNVQ.js +0 -19
  214. package/chunks/chunk-MF56TTC5.js +0 -15
  215. package/chunks/chunk-MT74WIQR.js +0 -41
  216. package/chunks/chunk-NRITRUZW.js +0 -10
  217. package/chunks/chunk-NRJQKVQV.js +0 -31
  218. package/chunks/chunk-OSAXBA7G.js +0 -10
  219. package/chunks/chunk-PO3V3I57.js +0 -23
  220. package/chunks/chunk-QXAJXTXV.js +0 -19
  221. package/chunks/chunk-RCO57B6F.js +0 -9
  222. package/chunks/chunk-SQZGZ3VT.js +0 -15
  223. package/chunks/chunk-TTZHPIH7.js +0 -237
  224. package/chunks/chunk-UYTXXUU7.js +0 -54
  225. package/chunks/chunk-VTPRO4NJ.js +0 -10
  226. package/chunks/chunk-VYW4IADX.js +0 -35
  227. package/chunks/chunk-VZDUZTW6.js +0 -113
  228. package/chunks/chunk-W5A2TON3.js +0 -10
  229. package/chunks/chunk-W7YBGJ4H.js +0 -28
  230. package/chunks/chunk-WLL3FNVL.js +0 -40
  231. package/chunks/chunk-WOAYU6LB.js +0 -13
  232. package/chunks/chunk-XA4HVHJ4.js +0 -18
  233. package/chunks/chunk-XN3PZIVL.js +0 -38
  234. package/chunks/chunk-Y3OZHZUF.js +0 -237
  235. package/chunks/chunk-YDIBNEGA.js +0 -9
  236. package/chunks/chunk-ZC5RZBNY.js +0 -33
  237. package/components/Comp/index.d.ts +0 -1
  238. package/node/files/index.d.ts +0 -9
  239. package/node/files/index.js +0 -21
  240. package/node/files/is-in-directory/index.d.ts +0 -1
  241. package/node/files/is-in-directory/index.js +0 -6
  242. package/node/files/read-write/index.d.ts +0 -9
  243. package/node/files/read-write/index.js +0 -6
  244. package/node/files/subpaths/index.d.ts +0 -34
  245. package/node/files/subpaths/index.js +0 -7
  246. package/node/image/index.d.ts +0 -9
  247. package/node/image/index.js +0 -74
  248. package/node/mxfb-cli-config/index.d.ts +0 -19
  249. package/node/mxfb-cli-config/index.js +0 -113
  250. package/node/process/index.d.ts +0 -8
  251. package/node/process/index.js +0 -20
  252. package/node/process/on-exit/index.d.ts +0 -5
  253. package/node/process/on-exit/index.js +0 -14
  254. package/shared/errors/index.d.ts +0 -138
  255. 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,11 @@
1
+ export declare const citiesData: {
2
+ paris11: {
3
+ readonly id: 27827;
4
+ readonly urlSuffix: "paris_11eme_arrondissement";
5
+ };
6
+ };
7
+ export type CityName = keyof typeof citiesData;
8
+ export type CityData = {
9
+ id: number;
10
+ urlSuffix: string;
11
+ };
@@ -0,0 +1,10 @@
1
+ export const citiesData = {
2
+ // Paris 11e
3
+ 'paris11': {
4
+ id: 27827,
5
+ urlSuffix: 'paris_11eme_arrondissement'
6
+ }
7
+ };
8
+ // Typecheck
9
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
10
+ const _citiesDataTypecheck = citiesData;
@@ -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
+ }