@mxfb/tools 1.0.0 → 1.0.3

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 (67) 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.js +67 -0
  7. package/agnostic/index.d.ts +5 -0
  8. package/agnostic/index.js +5 -0
  9. package/agnostic/poetry/generate-rhymes/index.d.ts +1 -0
  10. package/agnostic/poetry/generate-rhymes/index.js +125 -0
  11. package/agnostic/poetry/index.d.ts +1 -0
  12. package/agnostic/poetry/index.js +1 -0
  13. package/agnostic/scrapers/@meteociel/index.js +240 -186
  14. package/agnostic/scrapers/@meteociel/index.test.js +43 -0
  15. package/agnostic/scrapers/@meteociel/locations/index.js +9 -5
  16. package/agnostic/scrapers/@meteociel/types.js +1 -0
  17. package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.d.ts +22 -0
  18. package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.js +124 -0
  19. package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.test.d.ts +1 -0
  20. package/agnostic/scrapers/@tumblr/blogs/delabienbonnemusique/index.test.js +107 -0
  21. package/agnostic/scrapers/@tumblr/blogs/index.d.ts +8 -0
  22. package/agnostic/scrapers/@tumblr/blogs/index.js +23 -0
  23. package/agnostic/scrapers/@tumblr/index.d.ts +73 -0
  24. package/agnostic/scrapers/@tumblr/index.js +180 -0
  25. package/agnostic/scrapers/@tumblr/index.test.d.ts +1 -0
  26. package/agnostic/scrapers/@tumblr/index.test.js +319 -0
  27. package/agnostic/scrapers/@tumblr/types.d.ts +238 -0
  28. package/agnostic/scrapers/@tumblr/types.js +38 -0
  29. package/agnostic/scrapers/@tumblr/utils.d.ts +12 -0
  30. package/agnostic/scrapers/@tumblr/utils.js +407 -0
  31. package/agnostic/scrapers/index.d.ts +2 -0
  32. package/agnostic/scrapers/index.js +2 -0
  33. package/agnostic/weather/index.d.ts +1 -0
  34. package/agnostic/weather/index.js +1 -0
  35. package/agnostic/weather/types.d.ts +2 -2
  36. package/agnostic/weather/types.js +8 -0
  37. package/components/index.d.ts +1 -0
  38. package/components/index.js +1 -0
  39. package/components/public-classnames.d.ts +5 -0
  40. package/components/public-classnames.js +5 -0
  41. package/components/utils/index.d.ts +1 -0
  42. package/components/utils/index.js +12 -0
  43. package/components/utils/types.d.ts +3 -0
  44. package/components/utils/types.js +1 -0
  45. package/index.d.ts +3 -0
  46. package/index.js +3 -0
  47. package/node/headless-browser/index.d.ts +37 -0
  48. package/node/headless-browser/index.js +126 -0
  49. package/node/headless-browser/types.d.ts +47 -0
  50. package/node/headless-browser/types.js +5 -0
  51. package/node/index.js +1 -0
  52. package/node/index.test.d.ts +1 -0
  53. package/node/index.test.js +6 -0
  54. package/node/scrapers/@tumblr/index.d.ts +61 -0
  55. package/node/scrapers/@tumblr/index.js +182 -0
  56. package/node/scrapers/@tumblr/index.test.d.ts +1 -0
  57. package/node/scrapers/@tumblr/index.test.js +185 -0
  58. package/node/scrapers/@tumblr/types.d.ts +91 -0
  59. package/node/scrapers/@tumblr/types.js +1 -0
  60. package/node/scrapers/@tumblr/utils.d.ts +23 -0
  61. package/node/scrapers/@tumblr/utils.js +328 -0
  62. package/node/scrapers/index.d.ts +1 -0
  63. package/node/scrapers/index.js +1 -0
  64. package/package.json +197 -31
  65. package/chunks/chunk-JQLUR5LO.js +0 -12
  66. package/components/Comp/index.js +0 -0
  67. /package/{components/Comp/index.d.ts → agnostic/elo/index.test.d.ts} +0 -0
@@ -0,0 +1 @@
1
+ export * as generateRhymes from './generate-rhymes/index.js'
@@ -1,194 +1,248 @@
1
- import {
2
- citiesData
3
- } from "../../../chunks/chunk-JQLUR5LO.js";
4
-
5
- // src/agnostic/scrapers/@meteociel/index.ts
6
- import { JSDOM } from "jsdom";
7
- function getCityAndModeUrl(cityName, mode) {
8
- const modeUrlChunk = mode === "simple-gfs" ? "previsions" : null;
9
- if (modeUrlChunk === null) throw "invalid mode";
10
- return `https://www.meteociel.fr/${modeUrlChunk}/${citiesData[cityName].id}/${citiesData[cityName].urlSuffix}.htm`;
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`;
11
22
  }
12
- async function fetchPage(url) {
13
- const fetched = await fetch(url);
14
- const buffer = await fetched.arrayBuffer();
15
- const html = new TextDecoder("iso-8859-1").decode(buffer);
16
- return html;
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;
17
34
  }
18
- async function scrap(cityName, mode) {
19
- const url = getCityAndModeUrl(cityName, mode);
20
- const fetched = await fetchPage(url);
21
- switch (mode) {
22
- case "simple-gfs": {
23
- const scraped = scrapSimpleGfsPrevisions(fetched);
24
- return scraped.map((i) => ({
25
- ...i,
26
- location: {
27
- ...i.location,
28
- name: cityName
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
+ }));
29
57
  }
30
- }));
58
+ /* eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check */
59
+ default: throw new Error('unknown-mode');
31
60
  }
32
- default:
33
- throw "unknown-mode";
34
- }
35
61
  }
36
- function scrapSimpleGfsPrevisions(pageContent) {
37
- const dom = new JSDOM(pageContent);
38
- const { document } = dom.window;
39
- const COLLECTED_ON = /* @__PURE__ */ new Date();
40
- const h3Parent = Array.from(document.querySelectorAll("h3")).find((n) => n.textContent.includes("Pr\xE9visions m\xE9t\xE9o \xE0 3 jours"))?.parentElement;
41
- const issueTimeNode = Array.from(h3Parent?.childNodes ?? []).find((n) => n.textContent?.includes("R\xE9actualis\xE9 \xE0"));
42
- const dataTableNode = Array.from(document.querySelectorAll("tbody")).find((n) => {
43
- const trChldCnt = n.querySelectorAll("tr").length;
44
- return trChldCnt > 25 && trChldCnt < 35;
45
- });
46
- const forecastTableRows = Array.from(dataTableNode?.querySelectorAll("tr") ?? []).slice(2);
47
- const locationNode = Array.from(
48
- Array.from(document.querySelectorAll("p")).find((n) => n.textContent.includes("sont exp\xE9rimentales et d\xE9termin\xE9es \xE0 partir des donn\xE9es brutes de GFS, un mod\xE8le num\xE9rique am\xE9ricain"))?.childNodes ?? []
49
- ).find((n) => n.textContent?.startsWith("Localisation de"));
50
- const issuedOnRaw = issueTimeNode?.textContent?.match(/\b([0-9]{1,2}):([0-5][0-9])\b/)?.[0];
51
- if (issuedOnRaw === void 0) throw "no-refresh-time-found";
52
- const [issuedOnH, issuedOnM] = issuedOnRaw.split(":");
53
- const issuedOnYesteredayMaybe = /* @__PURE__ */ new Date();
54
- issuedOnYesteredayMaybe.setDate((/* @__PURE__ */ new Date()).getDate() - 1);
55
- issuedOnYesteredayMaybe.setHours(parseInt(issuedOnH));
56
- issuedOnYesteredayMaybe.setMinutes(parseInt(issuedOnM));
57
- issuedOnYesteredayMaybe.setSeconds(0);
58
- issuedOnYesteredayMaybe.setMilliseconds(0);
59
- const issuedOnYesteredayMaybeNowDiff = Math.abs((/* @__PURE__ */ new Date()).getTime() - issuedOnYesteredayMaybe.getTime());
60
- const issuedOnTodayMaybe = /* @__PURE__ */ new Date();
61
- issuedOnTodayMaybe.setHours(parseInt(issuedOnH));
62
- issuedOnTodayMaybe.setMinutes(parseInt(issuedOnM));
63
- issuedOnTodayMaybe.setSeconds(0);
64
- issuedOnTodayMaybe.setMilliseconds(0);
65
- const issuedOnTodayMaybeNowDiff = Math.abs((/* @__PURE__ */ new Date()).getTime() - issuedOnTodayMaybe.getTime());
66
- const ISSUED_ON = issuedOnYesteredayMaybeNowDiff > issuedOnTodayMaybeNowDiff ? issuedOnTodayMaybe : issuedOnYesteredayMaybe;
67
- function parseLocation(input) {
68
- const regex = /Latitude\s+([NS])\s+(\d+)°(\d+)'\s+\/\s+Longitude\s+([EW])\s+(\d+)°(\d+)'\s+\/\s+Altitude\s*:\s*(\d+)\s*m/;
69
- const match = input.match(regex);
70
- if (!match) throw new Error("Format de coordonn\xE9es invalide");
71
- const [, latDir, latDeg, latMin, lonDir, lonDeg, lonMin, altStr] = match;
72
- let lat = Number(latDeg) + Number(latMin) / 60;
73
- let lon = Number(lonDeg) + Number(lonMin) / 60;
74
- if (latDir === "S") lat = -1 * lat;
75
- if (lonDir === "W") lon = -1 * lon;
76
- const alt = Number(altStr);
77
- return {
78
- latitudeDeg: lat,
79
- longitudeDeg: lon,
80
- elevationM: alt
81
- };
82
- }
83
- let LOCATION;
84
- try {
85
- LOCATION = parseLocation(locationNode?.textContent ?? "");
86
- } catch (err) {
87
- throw "no-geolocation-found";
88
- }
89
- let currentDateLabel = null;
90
- const currentHour = /* @__PURE__ */ new Date();
91
- currentHour.setMinutes(0);
92
- currentHour.setSeconds(0);
93
- currentHour.setMilliseconds(0);
94
- let RETURNED = [];
95
- for (const row of forecastTableRows) {
96
- const cells = Array.from(row.querySelectorAll("td"));
97
- if (cells.length < 10 || cells.length > 11) continue;
98
- let dateCell = void 0;
99
- let otherCells = [];
100
- if (cells.length === 11) {
101
- dateCell = cells[0];
102
- otherCells.push(...cells.slice(1));
103
- } else {
104
- otherCells.push(...cells);
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
+ };
105
137
  }
106
- if (dateCell !== void 0) {
107
- currentDateLabel = dateCell.textContent;
138
+ let LOCATION;
139
+ try {
140
+ LOCATION = parseLocation(locationNode?.textContent ?? '');
108
141
  }
109
- const [
110
- hourCell,
111
- tempCell,
112
- _emptyCell1,
113
- windDirCell,
114
- windSpeedCell,
115
- gustSpeedCell,
116
- rainAmountCell,
117
- hygrometryCell,
118
- airPressureCell,
119
- weatherCell
120
- ] = otherCells;
121
- const hour = hourCell?.textContent ?? null;
122
- const temp = tempCell?.textContent ?? null;
123
- const windDir = windDirCell?.querySelector("img")?.getAttribute("title") ?? null;
124
- const [windDirName = null, windDirAngle = null] = windDir?.split(" : ") ?? [];
125
- const windSpeed = windSpeedCell?.textContent ?? null;
126
- const gustSpeed = gustSpeedCell?.textContent ?? null;
127
- const rainAmount = rainAmountCell?.textContent === "--" ? null : rainAmountCell?.textContent ?? null;
128
- const hygrometry = hygrometryCell?.textContent ?? null;
129
- const airPressure = airPressureCell?.textContent ?? null;
130
- const weather = weatherCell?.querySelector("img")?.getAttribute("title") ?? null;
131
- const rawDay = currentDateLabel;
132
- const foundDate = rawDay?.slice(3) ?? "0";
133
- const pMonthFullDate = new Date(currentHour);
134
- pMonthFullDate.setMonth(currentHour.getMonth() - 1);
135
- pMonthFullDate.setDate(parseInt(foundDate));
136
- pMonthFullDate.setHours(parseInt(hour ?? "00"));
137
- const curMonthFullDate = new Date(currentHour);
138
- curMonthFullDate.setDate(parseInt(foundDate));
139
- curMonthFullDate.setHours(parseInt(hour ?? "00"));
140
- const nMonthFullDate = new Date(currentHour);
141
- nMonthFullDate.setMonth(currentHour.getMonth() + 1);
142
- nMonthFullDate.setDate(parseInt(foundDate));
143
- nMonthFullDate.setHours(parseInt(hour ?? "00"));
144
- const eligibleDateTimesDiff = [
145
- Math.abs(currentHour.getTime() - pMonthFullDate.getTime()),
146
- Math.abs(currentHour.getTime() - curMonthFullDate.getTime()),
147
- Math.abs(currentHour.getTime() - nMonthFullDate.getTime())
148
- ];
149
- const minDateDiff = Math.min(...eligibleDateTimesDiff);
150
- const indexOfMinDateDiff = eligibleDateTimesDiff.indexOf(minDateDiff);
151
- const date = indexOfMinDateDiff === 0 ? pMonthFullDate : indexOfMinDateDiff === 1 ? curMonthFullDate : indexOfMinDateDiff === 2 ? nMonthFullDate : currentHour;
152
- const validityTo = new Date(date);
153
- validityTo.setHours(date.getHours() + 3);
154
- const VALIDITY = {
155
- from: date,
156
- to: validityTo
157
- };
158
- const TEMPERATURE = { tempC: parseFloat(temp?.replace(" \xB0C", "") ?? "") };
159
- const HUMIDITY = { relativePercent: parseFloat(hygrometry?.replace(" %", "") ?? "") };
160
- const WIND = {
161
- speedMs: parseFloat(windSpeed ?? ""),
162
- directionDeg: parseFloat(windDirAngle?.replace(" \xB0", "") ?? ""),
163
- gustMs: parseFloat(gustSpeed ?? "")
164
- };
165
- const PRESSURE = { stationLevelHpa: parseFloat(airPressure?.replace(" hPa", "") ?? "") };
166
- const SOURCE = {
167
- provider: "M\xE9t\xE9ociel",
168
- type: "model",
169
- model: "GFS"
170
- };
171
- RETURNED.push({
172
- issuedOn: ISSUED_ON,
173
- location: LOCATION,
174
- validity: VALIDITY,
175
- temperature: TEMPERATURE,
176
- humidity: HUMIDITY,
177
- wind: WIND,
178
- pressure: PRESSURE,
179
- precipitationMmh: parseFloat(rainAmount ?? ""),
180
- metadata: {
181
- source: SOURCE,
182
- description: weather ?? "unknown",
183
- collectedOn: COLLECTED_ON
184
- }
185
- });
186
- }
187
- return RETURNED;
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;
188
248
  }
189
- export {
190
- fetchPage,
191
- getCityAndModeUrl,
192
- scrap,
193
- scrapSimpleGfsPrevisions
194
- };
@@ -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
+ });
@@ -1,6 +1,10 @@
1
- import {
2
- citiesData
3
- } from "../../../../chunks/chunk-JQLUR5LO.js";
4
- export {
5
- citiesData
1
+ export const citiesData = {
2
+ // Paris 11e
3
+ 'paris11': {
4
+ id: 27827,
5
+ urlSuffix: 'paris_11eme_arrondissement'
6
+ }
6
7
  };
8
+ // Typecheck
9
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
10
+ const _citiesDataTypecheck = citiesData;
@@ -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;