All checks were successful
Deploy production / deploy (push) Successful in 1m12s
479 lines
18 KiB
TypeScript
479 lines
18 KiB
TypeScript
// Shared plumbing for the /stats collector (scripts/stats-fetch.ts): DB client, polite HTTP,
|
||
// fetch_run logging, and the normalised layer (stats.source / region / series / obs).
|
||
// Runs on the host (systemd user timers, see deploy/stats-collector) with DB_URL = app role.
|
||
import postgres from 'postgres';
|
||
|
||
if (!process.env.DB_URL) throw new Error('DB_URL is missing');
|
||
export const sql = postgres(process.env.DB_URL, {
|
||
max: 2,
|
||
connect_timeout: 15,
|
||
onnotice: () => {},
|
||
prepare: false
|
||
});
|
||
|
||
export const USER_AGENT =
|
||
'stanrunge.dev-stats/1.0 (+https://stanrunge.dev/stats; open-data collector; stan@stanrunge.dev)';
|
||
|
||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
/** Minimum gap between requests per host (CBS ≤5 req/s → 250 ms; OECD ≤20/min → 3.1 s). */
|
||
const hostGapMs: Record<string, number> = {
|
||
'datasets.cbs.nl': 250,
|
||
'opendata.cbs.nl': 250,
|
||
'sdmx.oecd.org': 3100,
|
||
'fred.stlouisfed.org': 1000,
|
||
'ec.europa.eu': 500,
|
||
'stats.bis.org': 1000,
|
||
'api.worldbank.org': 1000,
|
||
'www.imf.org': 1500,
|
||
'ourworldindata.org': 2000,
|
||
'owid-public.owid.io': 2000,
|
||
'api.energy-charts.info': 2000,
|
||
'www.rijksfinancien.nl': 1000,
|
||
'api.fiscaldata.treasury.gov': 1000,
|
||
'api.usaspending.gov': 500,
|
||
'public.ep-online.nl': 1000,
|
||
'www.cbs.cw': 2000,
|
||
'cuatro.sim-cdn.nl': 1000,
|
||
'sdmx.ilo.org': 3000,
|
||
'unstats.un.org': 1500,
|
||
'comtradeapi.un.org': 2500,
|
||
'data.overheid.nl': 500,
|
||
'dataverse.harvard.edu': 1000,
|
||
'github.com': 1000,
|
||
'partyfacts.herokuapp.com': 2000,
|
||
'en.wikipedia.org': 1000,
|
||
'data.europa.eu': 1000,
|
||
'webgate.ec.europa.eu': 2000,
|
||
'files.worldhappiness.report': 1000,
|
||
'trends.google.com': 3000,
|
||
'api.gdeltproject.org': 5500
|
||
};
|
||
const lastHit = new Map<string, number>();
|
||
|
||
export async function http(url: string, init: RequestInit = {}, tries = 4): Promise<Response> {
|
||
const host = new URL(url).host;
|
||
for (let attempt = 1; ; attempt++) {
|
||
const gap = hostGapMs[host] ?? 300;
|
||
const wait = (lastHit.get(host) ?? 0) + gap - Date.now();
|
||
if (wait > 0) await sleep(wait);
|
||
lastHit.set(host, Date.now());
|
||
let response: Response | undefined;
|
||
let failure: unknown;
|
||
try {
|
||
response = await fetch(url, {
|
||
...init,
|
||
headers: { 'user-agent': USER_AGENT, accept: '*/*', ...(init.headers ?? {}) },
|
||
signal: init.signal ?? AbortSignal.timeout(180_000)
|
||
});
|
||
if (response.ok) return response;
|
||
if (response.status !== 429 && response.status < 500) {
|
||
const body = (await response.text()).slice(0, 300);
|
||
throw new Error(`HTTP ${response.status} for ${url}: ${body}`);
|
||
}
|
||
failure = new Error(`HTTP ${response.status} for ${url}`);
|
||
} catch (error) {
|
||
if (error instanceof Error && error.message.startsWith('HTTP 4')) throw error;
|
||
failure = error;
|
||
}
|
||
if (attempt >= tries) throw failure;
|
||
const retryAfter = Number(response?.headers.get('retry-after'));
|
||
await sleep(
|
||
Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2000 * 2 ** attempt
|
||
);
|
||
}
|
||
}
|
||
|
||
export const getJson = async <T = unknown>(url: string) => (await (await http(url)).json()) as T;
|
||
export const getText = async (url: string) => (await http(url)).text();
|
||
|
||
export function log(...parts: unknown[]) {
|
||
console.log(new Date().toISOString(), ...parts);
|
||
}
|
||
|
||
/** Wraps one collector job in a stats.fetch_run row (rows, ok, error). */
|
||
export async function run(
|
||
sourceId: string,
|
||
job: string,
|
||
fn: (note: (warning: string) => void) => Promise<number>
|
||
): Promise<number> {
|
||
const [{ run_id }] = await sql`
|
||
insert into stats.fetch_run (source_id, job) values (${sourceId}, ${job}) returning run_id`;
|
||
const warnings: string[] = [];
|
||
try {
|
||
const rows = await fn((w) => warnings.push(w));
|
||
await sql`update stats.fetch_run set finished_at = now(), rows = ${rows}, ok = true,
|
||
error = ${warnings.length ? warnings.join('; ').slice(0, 4000) : null} where run_id = ${run_id}`;
|
||
log(
|
||
`${sourceId}/${job}: ok, ${rows} rows${warnings.length ? `, ${warnings.length} warnings` : ''}`
|
||
);
|
||
return rows;
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
await sql`update stats.fetch_run set finished_at = now(), ok = false,
|
||
error = ${[message, ...warnings].join('; ').slice(0, 4000)} where run_id = ${run_id}`;
|
||
log(`${sourceId}/${job}: FAILED ${message}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// ── Sources and regions ──────────────────────────────────────────────────────────────────
|
||
|
||
export const SOURCES = [
|
||
{
|
||
source_id: 'cbs',
|
||
name: 'CBS StatLine',
|
||
base_url: 'https://datasets.cbs.nl/odata/v1/CBS',
|
||
licence: 'CC BY 4.0',
|
||
key_required: false,
|
||
attribution: 'Source: CBS StatLine (Statistics Netherlands), CC BY 4.0'
|
||
},
|
||
{
|
||
source_id: 'eurostat',
|
||
name: 'Eurostat',
|
||
base_url: 'https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data',
|
||
licence: 'CC BY 4.0',
|
||
key_required: false,
|
||
attribution: 'Source: Eurostat, CC BY 4.0'
|
||
},
|
||
{
|
||
source_id: 'fred',
|
||
name: 'FRED (Federal Reserve Bank of St. Louis)',
|
||
base_url: 'https://fred.stlouisfed.org/graph/fredgraph.csv',
|
||
licence: 'Per series (EIA/BLS/Fed public domain, OECD CC BY 4.0)',
|
||
key_required: false,
|
||
attribution: 'Source: FRED, Federal Reserve Bank of St. Louis (original series sources apply)'
|
||
},
|
||
{
|
||
source_id: 'bis',
|
||
name: 'BIS central bank policy rates',
|
||
base_url: 'https://stats.bis.org/api/v2/data/dataflow/BIS/WS_CBPOL/1.0',
|
||
licence: 'Free reuse with attribution',
|
||
key_required: false,
|
||
attribution: 'Source: BIS, central bank policy rates (WS_CBPOL)'
|
||
},
|
||
{
|
||
source_id: 'worldbank',
|
||
name: 'World Bank World Development Indicators',
|
||
base_url: 'https://api.worldbank.org/v2',
|
||
licence: 'CC BY 4.0',
|
||
key_required: false,
|
||
attribution: 'Source: World Bank, World Development Indicators, CC BY 4.0'
|
||
},
|
||
{
|
||
source_id: 'imf',
|
||
name: 'IMF World Economic Outlook (DataMapper)',
|
||
base_url: 'https://www.imf.org/external/datamapper/api/v1',
|
||
licence: 'IMF copyright, free reuse with attribution',
|
||
key_required: false,
|
||
attribution: 'Source: IMF, World Economic Outlook (DataMapper)'
|
||
},
|
||
{
|
||
source_id: 'owid',
|
||
name: 'Our World in Data',
|
||
base_url: 'https://ourworldindata.org',
|
||
licence: 'CC BY 4.0',
|
||
key_required: false,
|
||
attribution:
|
||
'Source: Our World in Data (Maddison Project Database 2023; Energy Institute, Ember), CC BY 4.0'
|
||
},
|
||
{
|
||
source_id: 'energycharts',
|
||
name: 'Energy-Charts (Fraunhofer ISE)',
|
||
base_url: 'https://api.energy-charts.info',
|
||
licence: 'CC BY 4.0',
|
||
key_required: false,
|
||
attribution: 'Source: Energy-Charts.info (Fraunhofer ISE; ENTSO-E, SMARD), CC BY 4.0'
|
||
},
|
||
{
|
||
source_id: 'rijksfinancien',
|
||
name: 'Rijksfinanciën (Ministerie van Financiën)',
|
||
base_url: 'https://www.rijksfinancien.nl/open-data/api',
|
||
licence: 'CC0',
|
||
key_required: false,
|
||
attribution: 'Source: Rijksfinanciën open data, Ministerie van Financiën (CC0)'
|
||
},
|
||
{
|
||
source_id: 'ustreasury',
|
||
name: 'US Treasury Fiscal Data',
|
||
base_url: 'https://api.fiscaldata.treasury.gov/services/api/fiscal_service',
|
||
licence: 'Public domain (US government work)',
|
||
key_required: false,
|
||
attribution:
|
||
'Source: U.S. Treasury, Fiscal Data (Monthly Treasury Statement, Debt to the Penny)'
|
||
},
|
||
{
|
||
source_id: 'usaspending',
|
||
name: 'USAspending.gov',
|
||
base_url: 'https://api.usaspending.gov/api/v2',
|
||
licence: 'Public domain (US government work)',
|
||
key_required: false,
|
||
attribution: 'Source: USAspending.gov (U.S. Department of the Treasury)'
|
||
},
|
||
{
|
||
source_id: 'eponline',
|
||
name: 'EP-Online (RVO energy labels)',
|
||
base_url: 'https://public.ep-online.nl/api/v5',
|
||
licence: 'Open data (RVO), aggregated here; API key required',
|
||
key_required: true,
|
||
attribution:
|
||
'Source: EP-Online, Netherlands Enterprise Agency (RVO), aggregated by stanrunge.dev'
|
||
},
|
||
{
|
||
source_id: 'cbscw',
|
||
name: 'CBS Curaçao (Central Bureau of Statistics Curaçao)',
|
||
base_url: 'https://www.cbs.cw',
|
||
licence: 'Free reuse on condition that the source is mentioned (cbs.cw/disclaimer)',
|
||
key_required: false,
|
||
attribution: 'Source: Central Bureau of Statistics Curaçao (CBS Curaçao)'
|
||
},
|
||
{
|
||
source_id: 'cbcs',
|
||
name: 'Centrale Bank van Curaçao en Sint Maarten (CBCS)',
|
||
base_url: 'https://www.centralbank.cw',
|
||
licence: 'No open licence published; all rights reserved, shown with attribution',
|
||
key_required: false,
|
||
attribution: 'Source: Centrale Bank van Curaçao en Sint Maarten (CBCS), all rights reserved'
|
||
},
|
||
{
|
||
source_id: 'ilostat',
|
||
name: 'ILOSTAT (International Labour Organization)',
|
||
base_url: 'https://sdmx.ilo.org/rest',
|
||
licence: 'CC BY 4.0',
|
||
key_required: false,
|
||
attribution: 'Source: ILOSTAT, International Labour Organization, CC BY 4.0'
|
||
},
|
||
{
|
||
source_id: 'unsdg',
|
||
name: 'UN SDG Global Database',
|
||
base_url: 'https://unstats.un.org/sdgs/UNSDGAPIV5',
|
||
licence: 'Free use with attribution (UN terms of use)',
|
||
key_required: false,
|
||
attribution: 'Source: United Nations Global SDG Database (UN Statistics Division)'
|
||
},
|
||
{
|
||
source_id: 'comtrade',
|
||
name: 'UN Comtrade',
|
||
base_url: 'https://comtradeapi.un.org/public/v1/preview',
|
||
licence: 'Free use with attribution (UN Comtrade public preview)',
|
||
key_required: false,
|
||
attribution: 'Source: UN Comtrade Database (United Nations Statistics Division)'
|
||
},
|
||
{
|
||
source_id: 'kiesraad',
|
||
name: 'Kiesraad election results (data.overheid.nl)',
|
||
base_url: 'https://data.overheid.nl/dataset?q=verkiezingsuitslag',
|
||
licence: 'CC0 1.0',
|
||
key_required: false,
|
||
attribution: 'Source: Kiesraad, verkiezingsuitslagen (data.overheid.nl), CC0'
|
||
},
|
||
{
|
||
source_id: 'mitelection',
|
||
name: 'MIT Election Data and Science Lab, U.S. President 1976–2024',
|
||
base_url: 'https://doi.org/10.7910/DVN/42MVDX',
|
||
licence: 'CC0 1.0',
|
||
key_required: false,
|
||
attribution:
|
||
'Source: MIT Election Data and Science Lab, U.S. President 1976–2024 (Harvard Dataverse), CC0'
|
||
},
|
||
{
|
||
source_id: 'ches',
|
||
name: 'Chapel Hill Expert Survey (trend file) + V-Party + Party Facts',
|
||
base_url: 'https://www.chesdata.eu',
|
||
licence:
|
||
'Free for non-commercial research use with citation (CHES); V-Dem CC BY-SA 4.0; Party Facts CC BY-SA 4.0',
|
||
key_required: false,
|
||
attribution:
|
||
'Sources: Chapel Hill Expert Survey 1999–2024 (Jolly et al.); V-Dem V-Party v2 (Lührmann et al.); Party Facts (Döring & Regel)'
|
||
},
|
||
{
|
||
source_id: 'wikipedia',
|
||
name: 'Wikipedia opinion-polling tables',
|
||
base_url: 'https://en.wikipedia.org',
|
||
licence: 'CC BY-SA 4.0',
|
||
key_required: false,
|
||
attribution:
|
||
'Source: Wikipedia opinion-polling articles and their cited pollsters, CC BY-SA 4.0'
|
||
},
|
||
{
|
||
source_id: 'eurobarometer',
|
||
name: 'Standard Eurobarometer (volume A tables)',
|
||
base_url: 'https://data.europa.eu',
|
||
licence: 'Reuse authorised with acknowledgement (Commission Decision 2011/833/EU)',
|
||
key_required: false,
|
||
attribution: 'Source: European Commission, Standard Eurobarometer, © European Union'
|
||
},
|
||
{
|
||
source_id: 'whr',
|
||
name: 'World Happiness Report (Figure 2.1 data)',
|
||
base_url: 'https://worldhappiness.report',
|
||
licence: 'Free reuse with attribution',
|
||
key_required: false,
|
||
attribution: 'Source: World Happiness Report (Wellbeing Research Centre, Gallup World Poll)'
|
||
},
|
||
{
|
||
source_id: 'gtrends',
|
||
name: 'Google Trends, trending now (RSS)',
|
||
base_url: 'https://trends.google.com/trending/rss',
|
||
licence: 'Google Terms of Service; shown with attribution',
|
||
key_required: false,
|
||
attribution: 'Source: Google Trends'
|
||
},
|
||
{
|
||
source_id: 'gdelt',
|
||
name: 'GDELT Project DOC 2.0 API',
|
||
base_url: 'https://api.gdeltproject.org/api/v2/doc/doc',
|
||
licence: 'Open, unrestricted use with citation',
|
||
key_required: false,
|
||
attribution: 'Source: The GDELT Project'
|
||
}
|
||
] as const;
|
||
|
||
export const REGIONS: { region_code: string; name: string; kind: string; parent: string | null }[] =
|
||
[
|
||
{ region_code: 'WLD', name: 'World', kind: 'aggregate', parent: null },
|
||
{ region_code: 'EU27', name: 'European Union (27)', kind: 'aggregate', parent: 'WLD' },
|
||
{ region_code: 'EA', name: 'Euro area', kind: 'aggregate', parent: 'EU27' },
|
||
{ region_code: 'NLD', name: 'Netherlands', kind: 'country', parent: 'EU27' },
|
||
{ region_code: 'USA', name: 'United States', kind: 'country', parent: 'WLD' },
|
||
{ region_code: 'KOR', name: 'South Korea', kind: 'country', parent: 'WLD' },
|
||
{ region_code: 'CHN', name: 'China', kind: 'country', parent: 'WLD' },
|
||
{ region_code: 'CUW', name: 'Curaçao', kind: 'country', parent: 'WLD' },
|
||
{ region_code: 'DEU', name: 'Germany', kind: 'country', parent: 'EU27' },
|
||
{ region_code: 'FRA', name: 'France', kind: 'country', parent: 'EU27' },
|
||
{ region_code: 'BEL', name: 'Belgium', kind: 'country', parent: 'EU27' },
|
||
{ region_code: 'ITA', name: 'Italy', kind: 'country', parent: 'EU27' },
|
||
{ region_code: 'ESP', name: 'Spain', kind: 'country', parent: 'EU27' },
|
||
{ region_code: 'POL', name: 'Poland', kind: 'country', parent: 'EU27' },
|
||
{ region_code: 'SWE', name: 'Sweden', kind: 'country', parent: 'EU27' },
|
||
{ region_code: 'DNK', name: 'Denmark', kind: 'country', parent: 'EU27' },
|
||
{ region_code: 'AUT', name: 'Austria', kind: 'country', parent: 'EU27' },
|
||
{ region_code: 'GBR', name: 'United Kingdom', kind: 'country', parent: 'WLD' },
|
||
{ region_code: 'JPN', name: 'Japan', kind: 'country', parent: 'WLD' },
|
||
{ region_code: 'IND', name: 'India', kind: 'country', parent: 'WLD' },
|
||
{ region_code: 'BRA', name: 'Brazil', kind: 'country', parent: 'WLD' },
|
||
{ region_code: 'RUS', name: 'Russia', kind: 'country', parent: 'WLD' },
|
||
{ region_code: 'CR28', name: 'Oost-Zuid-Holland (COROP)', kind: 'corop', parent: 'NLD' },
|
||
{ region_code: 'GM0513', name: 'Gouda', kind: 'municipality', parent: 'CR28' },
|
||
{ region_code: 'GM0627', name: 'Waddinxveen', kind: 'municipality', parent: 'CR28' },
|
||
{ region_code: 'GM1901', name: 'Bodegraven-Reeuwijk', kind: 'municipality', parent: 'CR28' },
|
||
{ region_code: 'GM1892', name: 'Zuidplas', kind: 'municipality', parent: 'CR28' },
|
||
{ region_code: 'GM1931', name: 'Krimpenerwaard', kind: 'municipality', parent: 'CR28' },
|
||
{ region_code: 'GM0484', name: 'Alphen aan den Rijn', kind: 'municipality', parent: 'NLD' },
|
||
{ region_code: 'GM0632', name: 'Woerden', kind: 'municipality', parent: 'NLD' }
|
||
];
|
||
|
||
/** Countries compared on /stats/countries (ISO3), in display order. */
|
||
export const COMPARE_ISO3 = [
|
||
'NLD',
|
||
'USA',
|
||
'KOR',
|
||
'CHN',
|
||
'CUW',
|
||
'DEU',
|
||
'FRA',
|
||
'BEL',
|
||
'GBR',
|
||
'JPN',
|
||
'IND',
|
||
'BRA',
|
||
'RUS'
|
||
];
|
||
|
||
/**
|
||
* Batch insert inside an open transaction: one jsonb parameter per chunk, so big tables load fast
|
||
* without hitting the bind-parameter limit. Row keys are column names (snake_case).
|
||
*/
|
||
export async function insertChunks(
|
||
tx: postgres.TransactionSql,
|
||
table: string,
|
||
rows: Record<string, unknown>[],
|
||
conflict = '',
|
||
size = 20000
|
||
) {
|
||
if (!rows.length) return;
|
||
const cols = Object.keys(rows[0])
|
||
.map((k) => `"${k}"`)
|
||
.join(', ');
|
||
for (let i = 0; i < rows.length; i += size) {
|
||
await tx.unsafe(
|
||
`insert into stats.${table} (${cols}) select ${cols} from jsonb_populate_recordset(null::stats.${table}, ($1::text)::jsonb) ${conflict}`,
|
||
[JSON.stringify(rows.slice(i, i + size))] as never
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Gouda and its house-hunting ring (CBS gemeente codes). */
|
||
export const GOUDA_RING = ['GM0513', 'GM0627', 'GM1901', 'GM1892', 'GM1931', 'GM0484', 'GM0632'];
|
||
|
||
export async function ensureMeta() {
|
||
await sql`insert into stats.source ${sql(SOURCES as unknown as Record<string, unknown>[])}
|
||
on conflict (source_id) do update set name = excluded.name, base_url = excluded.base_url,
|
||
licence = excluded.licence, key_required = excluded.key_required, attribution = excluded.attribution`;
|
||
await sql`insert into stats.region ${sql(REGIONS)}
|
||
on conflict (region_code) do update set name = excluded.name, kind = excluded.kind, parent = excluded.parent`;
|
||
}
|
||
|
||
export type SeriesRow = {
|
||
series_id: string;
|
||
source_id: string;
|
||
source_code: string;
|
||
title: string;
|
||
unit: string;
|
||
freq: string;
|
||
region_code: string | null;
|
||
theme: string;
|
||
meta: Record<string, unknown>;
|
||
};
|
||
export type ObsRow = {
|
||
series_id: string;
|
||
period: string;
|
||
value: number | null;
|
||
status: string | null;
|
||
};
|
||
|
||
/** Replaces the normalised series + observations for the given series ids. */
|
||
export async function writeSeries(series: SeriesRow[], obs: ObsRow[]): Promise<number> {
|
||
if (!series.length) return 0;
|
||
const known = new Set(REGIONS.map((r) => r.region_code));
|
||
for (const s of series) if (s.region_code && !known.has(s.region_code)) s.region_code = null;
|
||
await sql.begin(async (tx) => {
|
||
for (let i = 0; i < series.length; i += 2000) {
|
||
const chunk = series
|
||
.slice(i, i + 2000)
|
||
.map((s) => ({ ...s, meta: tx.json(s.meta as never) }));
|
||
await tx`insert into stats.series ${tx(chunk)} on conflict (series_id) do update set
|
||
source_id = excluded.source_id, source_code = excluded.source_code, title = excluded.title,
|
||
unit = excluded.unit, freq = excluded.freq, region_code = excluded.region_code,
|
||
theme = excluded.theme, meta = excluded.meta`;
|
||
}
|
||
const ids = series.map((s) => s.series_id);
|
||
await tx`delete from stats.obs where series_id = any(${ids})`;
|
||
for (let i = 0; i < obs.length; i += 5000) {
|
||
await tx`insert into stats.obs ${tx(obs.slice(i, i + 5000), 'series_id', 'period', 'value', 'status')}
|
||
on conflict (series_id, period) do update set value = excluded.value, status = excluded.status, fetched_at = now()`;
|
||
}
|
||
});
|
||
return obs.length;
|
||
}
|
||
|
||
/** Period code → period start date (YYYY-MM-DD) and frequency. Handles CBS, Eurostat, ISO forms. */
|
||
export function parsePeriod(code: string): { date: string; freq: string } | null {
|
||
const c = code.trim();
|
||
let m: RegExpMatchArray | null;
|
||
if ((m = c.match(/^(\d{4})JJ00$/)) || (m = c.match(/^(\d{4})$/)))
|
||
return { date: `${m[1]}-01-01`, freq: 'A' };
|
||
if (
|
||
(m = c.match(/^(\d{4})MM(\d{2})$/)) ||
|
||
(m = c.match(/^(\d{4})-(\d{2})$/)) ||
|
||
(m = c.match(/^(\d{4})M(\d{2})$/))
|
||
)
|
||
return { date: `${m[1]}-${m[2]}-01`, freq: 'M' };
|
||
if ((m = c.match(/^(\d{4})KW0?(\d)$/)) || (m = c.match(/^(\d{4})-?Q(\d)$/)))
|
||
return { date: `${m[1]}-${String((Number(m[2]) - 1) * 3 + 1).padStart(2, '0')}-01`, freq: 'Q' };
|
||
if ((m = c.match(/^(\d{4})-?S(\d)$/)) || (m = c.match(/^(\d{4})HJ0?(\d)$/)))
|
||
return { date: `${m[1]}-${m[2] === '1' ? '01' : '07'}-01`, freq: 'S' };
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(c)) return { date: c, freq: 'D' };
|
||
return null;
|
||
}
|