amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 4)
ee888e1 verified
Raw
History Blame Contribute Delete
32.7 kB
import { createHash } from 'node:crypto';
import { decodeHtmlEntities } from '../_html-entities.mjs';
import { readCanonicalValue } from '../_seed-utils.mjs';
import {
CHINA_MACRO_CACHE_KEY,
CHINA_MACRO_PROVENANCE_FAMILY,
CHINA_MACRO_PUBLISHER_IDS,
CHINA_MACRO_REQUIRED_SERIES,
chinaMacroObservationDateMs,
isChinaMacroObservationStale,
} from '../_china-macro-contract.mjs';
import {
assertRobotsAllowed,
checkRobots,
fetchText,
findReleaseUrl,
reasonFor,
requestBudget,
robotsDisallowAll,
} from './source-runtime.mjs';
import {
GACC_MAX_REQUESTS_PER_RUN,
GACC_ROBOTS_URL,
NBS_LIST_URL,
NBS_MAX_REQUESTS_PER_RUN,
NBS_ROBOTS_URL,
PBOC_MAX_REQUESTS_PER_RUN,
PBOC_ROBOTS_URL,
PUBLISHERS,
SAFE_LIST_URL,
SAFE_MAX_REQUESTS_PER_RUN,
SAFE_ROBOTS_URL,
SOURCE_POLICIES,
UNAVAILABLE_DEFINITIONS,
} from './source-contracts.mjs';
import {
buildChinaMacroPillars,
buildChinaMacroSnapshot,
} from './snapshot-builder.mjs';
export { CHINA_MACRO_CACHE_KEY };
export { buildChinaMacroPillars, buildChinaMacroSnapshot };
export {
GACC_MAX_REQUESTS_PER_RUN,
NBS_MAX_REQUESTS_PER_RUN,
PBOC_MAX_REQUESTS_PER_RUN,
SAFE_MAX_REQUESTS_PER_RUN,
};
const CONTRACT_VERSION = 'decision-signal-provenance/v1';
const MAX_VINTAGES_PER_SERIES = 24;
function isRecord(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}
function contentHash(value) {
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
}
function revisionMarker(title) {
if (/preliminary|初步/i.test(title)) return 'preliminary';
if (/correct|更正/i.test(title)) return 'corrected';
if (/revis|修订/i.test(title)) return 'revised';
return 'original';
}
function semanticFingerprint(observation) {
return contentHash(JSON.stringify({
seriesId: observation.seriesId,
observationPeriod: observation.observationPeriod,
periodKind: observation.periodKind,
value: observation.value,
comparisonValue: observation.comparisonValue,
comparisonBasis: observation.comparisonBasis,
unit: observation.unit,
seasonalAdjustment: observation.seasonalAdjustment,
revisionMarker: revisionMarker(observation.releaseTitle),
}));
}
function decodeHtml(value) {
// The helper decodes ` ` to a literal U+00A0; normalize it back to a
// plain space to keep this decoder's historical ` | ` -> ' '
// contract for `metaContent` (which only trims, never collapses).
return decodeHtmlEntities(String(value)).replace(/\u00A0/g, ' ');
}
function stripHtml(value) {
return decodeHtml(
String(value)
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' '),
).replace(/\s+/g, ' ').trim();
}
function metaContent(html, name) {
const patterns = [
new RegExp(`<meta[^>]+name=["']${name}["'][^>]+content=["']([^"']*)["'][^>]*>`, 'i'),
new RegExp(`<meta[^>]+content=["']([^"']*)["'][^>]+name=["']${name}["'][^>]*>`, 'i'),
];
for (const pattern of patterns) {
const match = pattern.exec(html);
if (match?.[1]) return decodeHtml(match[1]).trim();
}
return '';
}
function requireMatch(value, pattern, label) {
const match = pattern.exec(value);
if (!match) throw new Error(`MALFORMED_RELEASE:${label}`);
return match;
}
function signedValue(direction, magnitude) {
const value = Number(magnitude);
if (!Number.isFinite(value)) throw new Error('MALFORMED_RELEASE:NON_NUMERIC_VALUE');
return /decrease|down|下降|减少/i.test(direction) ? -value : value;
}
function directionFromChange(value) {
if (!Number.isFinite(value)) return 'unavailable';
if (value > 0) return 'strengthening';
if (value < 0) return 'weakening';
return 'unchanged';
}
function nbsPublicationTime(html) {
const value = metaContent(html, 'PubDate');
const match = requireMatch(value, /^(\d{4})\/(\d{2})\/(\d{2})\s+(\d{2}):(\d{2})$/, 'NBS_PUBLICATION_TIME');
return {
value: new Date(Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]) - 8,
Number(match[5]),
)).toISOString(),
precision: 'instant',
};
}
function safePublicationTime(html) {
const value = metaContent(html, 'PubDate');
requireMatch(value, /^\d{4}-\d{2}-\d{2}$/, 'SAFE_PUBLICATION_TIME');
return { value, precision: 'day' };
}
function englishMonthPeriod(title) {
const monthNames = {
january: 1, february: 2, march: 3, april: 4, may: 5, june: 6,
july: 7, august: 8, september: 9, october: 10, november: 11, december: 12,
};
const match = requireMatch(title, /(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{4})/i, 'OBSERVATION_PERIOD');
return `${match[2]}-${String(monthNames[match[1].toLowerCase()]).padStart(2, '0')}`;
}
function chineseMonthPeriod(value) {
const match = requireMatch(value, /(\d{4})年(\d{1,2})月/, 'OBSERVATION_PERIOD');
return `${match[1]}-${String(Number(match[2])).padStart(2, '0')}`;
}
function known(value) {
return { status: 'known', value };
}
function unknown(reason) {
return { status: 'unknown', reason };
}
function notApplicable(reason) {
return { status: 'not_applicable', reason };
}
function buildProvenance(observation, revision) {
const publisher = PUBLISHERS[observation.publisherKey];
const vintageId = `${observation.seriesId}:${observation.observationPeriod}:v${revision.sequence}`;
const signalId = `signal:${vintageId}`;
return {
contractVersion: CONTRACT_VERSION,
signalId,
familyId: CHINA_MACRO_PROVENANCE_FAMILY,
claims: {
publisher: known({
id: publisher.id,
name: publisher.name,
type: 'official_government',
registryReference: {
sourceName: publisher.sourceName,
sourceType: 'gov',
propagandaRisk: 'high',
},
}),
source_url: known(observation.sourceUrl),
original_reference: known({
kind: 'observation',
id: vintageId,
contentHash: observation.contentHash,
}),
original_language: known(observation.originalLanguage),
translation: notApplicable('The normalized signal is numeric and carries no translated text.'),
observation_time: known({
role: 'observation',
value: observation.observationPeriod,
precision: 'month',
}),
effective_time: unknown('The release does not declare a separate effective date.'),
publication_time: known({
role: 'publication',
value: observation.releaseTime,
precision: observation.releasePrecision,
}),
retrieval_time: known({
role: 'retrieval',
value: observation.retrievalTime,
precision: 'instant',
}),
revision: known({
vintageId,
sequence: revision.sequence,
state: revision.state,
}),
supersession: known({ state: 'current' }),
extraction_confidence: known({
score: observation.extractionConfidence,
method: 'reviewed-release-regex/v1',
}),
classification_confidence: notApplicable('The canonical series identity is configured rather than inferred.'),
corroboration: unknown('No independent comparison has been performed.'),
transport_freshness: known({
state: 'fresh',
assessedAt: observation.retrievalTime,
lastSuccessAt: observation.retrievalTime,
}),
content_freshness: known({
state: observation.stale ? 'stale' : 'current',
assessedAt: observation.retrievalTime,
contentAsOf: observation.observationPeriod,
}),
derivation: notApplicable('This is a source observation, not a computed output.'),
},
};
}
function supersedeVintage(vintage, relatedSignalId) {
const cloned = structuredClone(vintage);
if (isRecord(cloned.provenance?.claims?.supersession)) {
cloned.provenance.claims.supersession = known({
state: 'superseded',
relatedSignalId,
reason: 'A later official release changed the same series and observation period.',
});
}
cloned.supersededBy = relatedSignalId;
return cloned;
}
function vintageFromObservation(observation) {
return {
seriesId: observation.seriesId,
vintageId: observation.vintageId,
sequence: observation.revisionSequence,
state: observation.revisionState,
value: observation.value,
observationPeriod: observation.observationPeriod,
periodKind: observation.periodKind,
releaseTime: observation.releaseTime,
retrievalTime: observation.retrievalTime,
sourceUrl: observation.sourceUrl,
supersededBy: '',
semanticFingerprint: observation.semanticFingerprint,
provenance: observation.provenance,
};
}
function completeObservation(base, previousObservation) {
const previous = isRecord(previousObservation)
&& previousObservation.seriesId === base.seriesId
? previousObservation
: null;
const priorVintages = Array.isArray(previous?.vintages)
? structuredClone(previous.vintages)
: (previous ? [vintageFromObservation(previous)] : []);
const nextBase = {
...base,
semanticFingerprint: semanticFingerprint(base),
};
const periodVintages = priorVintages
.map((vintage, index) => ({ vintage, index }))
.filter(({ vintage }) => vintage.observationPeriod === base.observationPeriod)
.sort((left, right) => Number(left.vintage.sequence) - Number(right.vintage.sequence));
const latestPeriod = periodVintages.at(-1);
const sameContent = latestPeriod?.vintage.semanticFingerprint === nextBase.semanticFingerprint;
const revisionSequence = latestPeriod
? Math.max(1, Number(latestPeriod.vintage.sequence) || 1) + (sameContent ? 0 : 1)
: 1;
const marker = revisionMarker(base.releaseTitle);
const revisionState = sameContent
? String(latestPeriod?.vintage.state || marker)
: latestPeriod
? (marker === 'corrected' ? 'corrected' : 'revised')
: marker === 'corrected' || marker === 'revised'
? 'original'
: marker;
const observation = {
...nextBase,
revisionState,
revisionSequence,
};
observation.provenance = buildProvenance(observation, {
sequence: revisionSequence,
state: revisionState,
});
observation.vintageId = observation.provenance.claims.revision.value.vintageId;
if (sameContent && latestPeriod) {
const currentIndex = latestPeriod.index;
const refreshed = vintageFromObservation(observation);
priorVintages[currentIndex] = refreshed;
} else {
if (latestPeriod) {
priorVintages[latestPeriod.index] = supersedeVintage(
priorVintages[latestPeriod.index],
observation.provenance.signalId,
);
}
priorVintages.push(vintageFromObservation(observation));
}
const vintages = priorVintages.slice(-MAX_VINTAGES_PER_SERIES);
if (previous && previous.observationPeriod > observation.observationPeriod) {
return markRetainedTransport({
...structuredClone(previous),
vintages,
}, base.retrievalTime, {
state: 'fresh',
failureReason: '',
});
}
observation.vintages = vintages;
return observation;
}
function baseObservation(definition, parsed, html, options) {
const retrievalTime = options?.retrievalTime || new Date().toISOString();
const observedAt = chinaMacroObservationDateMs(parsed.observationPeriod);
const publishedAt = Date.parse(parsed.publication.value);
const retrievedAt = Date.parse(retrievalTime);
if (
observedAt == null
|| !Number.isFinite(publishedAt)
|| !Number.isFinite(retrievedAt)
|| observedAt > publishedAt
|| publishedAt > retrievedAt
) {
throw new Error('MALFORMED_RELEASE:TEMPORAL_ORDER');
}
const stale = isChinaMacroObservationStale(
definition.seriesId,
parsed.observationPeriod,
retrievedAt,
);
return completeObservation({
...definition,
geography: 'CN',
seasonalAdjustment: definition.seasonalAdjustment || 'not_seasonally_adjusted',
value: parsed.value,
comparisonValue: parsed.comparisonValue,
comparisonBasis: parsed.comparisonBasis,
observationPeriod: parsed.observationPeriod,
releaseTime: parsed.publication.value,
releasePrecision: parsed.publication.precision,
retrievalTime,
direction: directionFromChange(parsed.comparisonValue),
directionReason: parsed.directionReason,
transportStatus: 'fresh',
transportFailureReason: '',
stale,
unavailableReason: stale ? 'STALE_OBSERVATION' : '',
sourceUrl: options?.sourceUrl || definition.defaultSourceUrl,
releaseTitle: parsed.releaseTitle,
contentHash: contentHash(html),
}, options?.previousObservation);
}
export function parseNbsIndustrialRelease(html, options = {}) {
const releaseTitle = metaContent(html, 'ArticleTitle');
const text = stripHtml(html);
const match = requireMatch(
text,
/total value added of industrial enterprises above the designated size\s+(increased|decreased) by\s+([\d.]+)%\s+year on year/i,
'NBS_INDUSTRIAL_VALUE_ADDED',
);
const value = signedValue(match[1], match[2]);
return baseObservation({
seriesId: 'nbs_industrial_value_added_yoy',
label: 'Industrial Value Added (YoY)',
pillar: 'activity',
unit: '%',
periodKind: 'month',
source: PUBLISHERS.nbs.name,
defaultSourceUrl: 'https://www.stats.gov.cn/english/PressRelease/',
publisherKey: 'nbs',
originalLanguage: 'en',
extractionConfidence: 0.98,
}, {
value,
comparisonValue: value,
comparisonBasis: 'year_over_year',
directionReason: value > 0 ? 'POSITIVE_YEAR_OVER_YEAR_CHANGE' : value < 0 ? 'NEGATIVE_YEAR_OVER_YEAR_CHANGE' : 'ZERO_YEAR_OVER_YEAR_CHANGE',
observationPeriod: englishMonthPeriod(releaseTitle),
publication: nbsPublicationTime(html),
releaseTitle,
}, html, options);
}
export function parseNbsFaiRelease(html, options = {}) {
const releaseTitle = metaContent(html, 'ArticleTitle');
const text = stripHtml(html);
const match = requireMatch(
text,
/national investment in fixed assets \(excluding rural households\).*?year-on-year (increase|decrease) of\s+([\d.]+)%/i,
'NBS_FIXED_ASSET_INVESTMENT',
);
const value = signedValue(match[1], match[2]);
return baseObservation({
seriesId: 'nbs_fixed_asset_investment_yoy',
label: 'Fixed-Asset Investment (YoY)',
pillar: 'investment_property',
unit: '%',
periodKind: 'cumulative_year',
source: PUBLISHERS.nbs.name,
defaultSourceUrl: 'https://www.stats.gov.cn/english/PressRelease/',
publisherKey: 'nbs',
originalLanguage: 'en',
extractionConfidence: 0.98,
}, {
value,
comparisonValue: value,
comparisonBasis: 'year_over_year',
directionReason: value > 0 ? 'POSITIVE_YEAR_OVER_YEAR_CHANGE' : value < 0 ? 'NEGATIVE_YEAR_OVER_YEAR_CHANGE' : 'ZERO_YEAR_OVER_YEAR_CHANGE',
observationPeriod: englishMonthPeriod(releaseTitle),
publication: nbsPublicationTime(html),
releaseTitle,
}, html, options);
}
export function parseNbsPropertyRelease(html, options = {}) {
const releaseTitle = metaContent(html, 'ArticleTitle');
const text = stripHtml(html);
const match = requireMatch(
text,
/investment in real estate development.*?year-on-year (increase|decrease) of\s+([\d.]+)%/i,
'NBS_REAL_ESTATE_INVESTMENT',
);
const value = signedValue(match[1], match[2]);
return baseObservation({
seriesId: 'nbs_real_estate_investment_yoy',
label: 'Real Estate Development Investment (YoY)',
pillar: 'investment_property',
unit: '%',
periodKind: 'cumulative_year',
source: PUBLISHERS.nbs.name,
defaultSourceUrl: 'https://www.stats.gov.cn/english/PressRelease/',
publisherKey: 'nbs',
originalLanguage: 'en',
extractionConfidence: 0.98,
}, {
value,
comparisonValue: value,
comparisonBasis: 'year_over_year',
directionReason: value > 0 ? 'POSITIVE_YEAR_OVER_YEAR_CHANGE' : value < 0 ? 'NEGATIVE_YEAR_OVER_YEAR_CHANGE' : 'ZERO_YEAR_OVER_YEAR_CHANGE',
observationPeriod: englishMonthPeriod(releaseTitle),
publication: nbsPublicationTime(html),
releaseTitle,
}, html, options);
}
export function parseSafeReserveRelease(html, options = {}) {
const releaseTitle = metaContent(html, 'ArticleTitle');
const description = metaContent(html, 'Description');
const valueMatch = requireMatch(description, /外汇储备规模为\s*([\d.]+)\s*亿美元/, 'SAFE_FX_RESERVES');
const changeMatch = requireMatch(description, /(?:降幅|升幅)为\s*([\d.]+)%/, 'SAFE_FX_RESERVES_CHANGE');
const change = /下降|降幅/.test(description) ? -Number(changeMatch[1]) : Number(changeMatch[1]);
return baseObservation({
seriesId: 'safe_fx_reserves',
label: 'Foreign-Exchange Reserves',
pillar: 'external_pressure',
unit: 'USD 100 million',
periodKind: 'point_in_time',
source: PUBLISHERS.safe.name,
defaultSourceUrl: SAFE_LIST_URL,
publisherKey: 'safe',
originalLanguage: 'zh-CN',
extractionConfidence: 0.99,
}, {
value: Number(valueMatch[1]),
comparisonValue: change,
comparisonBasis: 'month_over_month_percent_change',
directionReason: change > 0 ? 'RESERVES_INCREASED' : change < 0 ? 'RESERVES_DECREASED' : 'RESERVES_UNCHANGED',
observationPeriod: chineseMonthPeriod(`${releaseTitle} ${description}`),
publication: safePublicationTime(html),
releaseTitle,
}, html, options);
}
export function parseSafeSettlementRelease(html, options = {}) {
const releaseTitle = metaContent(html, 'ArticleTitle');
const description = metaContent(html, 'Description');
const match = requireMatch(
description,
/(\d{4})年(\d{1,2})月,银行结汇\s*([\d.]+)\s*亿元人民币,售汇\s*([\d.]+)\s*亿元人民币/,
'SAFE_BANK_SETTLEMENT',
);
const settlement = Number(match[3]);
return baseObservation({
seriesId: 'safe_bank_fx_settlement',
label: 'Bank FX Settlement',
pillar: 'external_pressure',
unit: 'CNY 100 million',
periodKind: 'month',
source: PUBLISHERS.safe.name,
defaultSourceUrl: SAFE_LIST_URL,
publisherKey: 'safe',
originalLanguage: 'zh-CN',
extractionConfidence: 0.99,
}, {
value: settlement,
comparisonValue: null,
comparisonBasis: 'not_available',
directionReason: 'NO_COMPARABLE_OFFICIAL_PRIOR_PERIOD',
observationPeriod: `${match[1]}-${String(Number(match[2])).padStart(2, '0')}`,
publication: safePublicationTime(html),
releaseTitle,
}, html, options);
}
function unavailableObservation(definition, reason) {
return {
...definition,
geography: 'CN',
seasonalAdjustment: 'unknown',
value: null,
comparisonValue: null,
comparisonBasis: '',
observationPeriod: '',
releaseTime: '',
releasePrecision: '',
retrievalTime: '',
direction: 'unavailable',
directionReason: reason,
transportStatus: 'blocked',
transportFailureReason: reason,
stale: false,
unavailableReason: reason,
revisionState: 'unavailable',
vintageId: '',
revisionSequence: 0,
provenance: null,
vintages: [],
};
}
function markRetainedTransport(previousObservation, checkedAt, {
state,
failureReason,
}) {
const observation = structuredClone(previousObservation);
const previousLastSuccessAt = observation.provenance
?.claims?.transport_freshness?.value?.lastSuccessAt;
const lastSuccessAt = state === 'fresh'
? checkedAt
: typeof previousLastSuccessAt === 'string' && previousLastSuccessAt
? previousLastSuccessAt
: observation.retrievalTime;
const stale = isChinaMacroObservationStale(
observation.seriesId,
observation.observationPeriod,
Date.parse(checkedAt),
);
observation.stale = stale;
observation.unavailableReason = stale ? 'STALE_OBSERVATION' : '';
observation.transportStatus = state;
observation.transportFailureReason = failureReason;
if (stale) {
observation.direction = 'unavailable';
observation.directionReason = 'STALE_OBSERVATION';
}
const updateFreshness = (provenance) => {
if (!isRecord(provenance?.claims)) return;
provenance.claims.transport_freshness = known({
state,
assessedAt: checkedAt,
lastSuccessAt,
});
provenance.claims.content_freshness = known({
state: stale ? 'stale' : 'current',
assessedAt: checkedAt,
contentAsOf: observation.observationPeriod,
});
};
updateFreshness(observation.provenance);
const currentVintage = Array.isArray(observation.vintages)
? observation.vintages.find((vintage) => vintage.vintageId === observation.vintageId)
: null;
if (currentVintage) updateFreshness(currentVintage.provenance);
return observation;
}
function preserveFailedRequiredSource({
source,
error,
seriesIds,
previousById,
checkedAt,
}) {
if (!error) return [];
const previous = seriesIds.map((seriesId) => previousById.get(seriesId));
const canPreserve = previous.every((observation) => (
isRecord(observation)
&& Number.isFinite(observation.value)
&& isRecord(observation.provenance)
&& observation.provenance.familyId === CHINA_MACRO_PROVENANCE_FAMILY
));
if (!canPreserve) throw requiredSourceError(source, error);
const reason = reasonFor(error);
return previous.map((observation) => (
markRetainedTransport(observation, checkedAt, {
state: 'error',
failureReason: reason,
})
));
}
function sourceDecision({
publisherId,
source,
host,
status,
reason,
checkedAt,
redirectBehavior,
requestBudget,
requestCount,
robotsStatus,
termsStatus,
sourceUrl,
}) {
return {
publisherId,
source,
host,
status,
reason,
checkedAt,
redirectBehavior,
requestBudget,
requestCount,
robotsStatus,
termsStatus,
sourceUrl,
};
}
function requiredSourceError(source, error) {
const reason = reasonFor(error);
const wrapped = new Error(`${source}_REQUIRED_SOURCE_UNAVAILABLE:${reason}`);
wrapped.nonRetryable = true;
return wrapped;
}
export async function fetchChinaMacroSnapshot({
now = Date.now(),
fetchFn = globalThis.fetch,
readCachedFn = readCanonicalValue,
onDecision = (entry) => console.log(JSON.stringify({
event: 'china_macro_source_preflight',
...entry,
})),
} = {}) {
const checkedAt = new Date(now).toISOString();
let previousSnapshot = null;
try {
previousSnapshot = await readCachedFn(CHINA_MACRO_CACHE_KEY);
} catch {
previousSnapshot = null;
}
const previousById = new Map(
(Array.isArray(previousSnapshot?.observations) ? previousSnapshot.observations : [])
.map((observation) => [observation.seriesId, observation]),
);
const decisions = [];
const record = (entry) => {
decisions.push(entry);
onDecision(entry);
};
const observations = [];
const nbsDecisionBase = Object.freeze({
publisherId: CHINA_MACRO_PUBLISHER_IDS.nbs,
source: PUBLISHERS.nbs.name,
host: 'www.stats.gov.cn',
checkedAt,
requestBudget: NBS_MAX_REQUESTS_PER_RUN,
termsStatus: 'reviewed_2026-07-25_attribution_required',
sourceUrl: NBS_LIST_URL,
});
const safeDecisionBase = Object.freeze({
publisherId: CHINA_MACRO_PUBLISHER_IDS.safe,
source: PUBLISHERS.safe.name,
host: 'www.safe.gov.cn',
checkedAt,
requestBudget: SAFE_MAX_REQUESTS_PER_RUN,
termsStatus: 'reviewed_2026-07-25_facts_only_attribution_required',
sourceUrl: SAFE_LIST_URL,
});
const nbsBudget = requestBudget(NBS_MAX_REQUESTS_PER_RUN);
let nbsRedirectBehavior = 'none';
let nbsRobotsStatus = 'unknown';
let nbsError = null;
try {
const robots = await checkRobots(fetchFn, NBS_ROBOTS_URL, {
policy: SOURCE_POLICIES.nbsRobots,
budget: nbsBudget,
candidatePaths: [new URL(NBS_LIST_URL).pathname],
onRedirect: (state) => { nbsRedirectBehavior = state; },
});
nbsRobotsStatus = robots.status;
const listing = await fetchText(fetchFn, NBS_LIST_URL, {
policy: SOURCE_POLICIES.nbs,
budget: nbsBudget,
assertTargetAllowed: (url) => assertRobotsAllowed(robots.text, [url.pathname]),
onRedirect: (state) => { nbsRedirectBehavior = state; },
});
const industrialUrl = findReleaseUrl(
listing.text,
listing.url,
/Industrial Production Operation/i,
'NBS_INDUSTRIAL',
SOURCE_POLICIES.nbs,
);
const fixedAssetUrl = findReleaseUrl(
listing.text,
listing.url,
/Investment in Fixed Assets/i,
'NBS_FIXED_ASSET',
SOURCE_POLICIES.nbs,
);
const propertyUrl = findReleaseUrl(
listing.text,
listing.url,
/Investment in Real Estate Development/i,
'NBS_PROPERTY',
SOURCE_POLICIES.nbs,
);
assertRobotsAllowed(robots.text, [industrialUrl, fixedAssetUrl, propertyUrl].map(
(url) => new URL(url).pathname,
));
const pages = [];
for (const url of [industrialUrl, fixedAssetUrl, propertyUrl]) {
const page = await fetchText(fetchFn, url, {
policy: SOURCE_POLICIES.nbs,
budget: nbsBudget,
assertTargetAllowed: (target) => assertRobotsAllowed(robots.text, [target.pathname]),
onRedirect: (state) => { nbsRedirectBehavior = state; },
});
pages.push(page);
}
observations.push(
parseNbsIndustrialRelease(pages[0].text, {
retrievalTime: checkedAt,
sourceUrl: pages[0].url,
previousObservation: previousById.get('nbs_industrial_value_added_yoy'),
}),
parseNbsFaiRelease(pages[1].text, {
retrievalTime: checkedAt,
sourceUrl: pages[1].url,
previousObservation: previousById.get('nbs_fixed_asset_investment_yoy'),
}),
parseNbsPropertyRelease(pages[2].text, {
retrievalTime: checkedAt,
sourceUrl: pages[2].url,
previousObservation: previousById.get('nbs_real_estate_investment_yoy'),
}),
);
record(sourceDecision({
...nbsDecisionBase,
robotsStatus: nbsRobotsStatus,
status: 'accepted',
reason: 'OK',
redirectBehavior: nbsRedirectBehavior,
requestCount: nbsBudget.count,
sourceUrl: listing.url,
}));
} catch (error) {
nbsError = error;
if (nbsRobotsStatus === 'unknown') nbsRobotsStatus = 'unavailable';
record(sourceDecision({
...nbsDecisionBase,
robotsStatus: nbsRobotsStatus,
status: 'blocked',
reason: reasonFor(error),
redirectBehavior: nbsRedirectBehavior,
requestCount: nbsBudget.count,
}));
}
const safeBudget = requestBudget(SAFE_MAX_REQUESTS_PER_RUN);
let safeRedirectBehavior = 'none';
let safeRobotsStatus = 'unknown';
let safeError = null;
try {
const robots = await checkRobots(fetchFn, SAFE_ROBOTS_URL, {
policy: SOURCE_POLICIES.safeRobots,
budget: safeBudget,
candidatePaths: [new URL(SAFE_LIST_URL).pathname],
onRedirect: (state) => { safeRedirectBehavior = state; },
});
safeRobotsStatus = robots.status;
const listing = await fetchText(fetchFn, SAFE_LIST_URL, {
policy: SOURCE_POLICIES.safe,
budget: safeBudget,
assertTargetAllowed: (url) => assertRobotsAllowed(robots.text, [url.pathname]),
onRedirect: (state) => { safeRedirectBehavior = state; },
});
const reserveUrl = findReleaseUrl(
listing.text,
listing.url,
/外汇储备规模数据/,
'SAFE_RESERVES',
SOURCE_POLICIES.safe,
);
const settlementUrl = findReleaseUrl(
listing.text,
listing.url,
/银行结售汇/,
'SAFE_SETTLEMENT',
SOURCE_POLICIES.safe,
);
assertRobotsAllowed(robots.text, [reserveUrl, settlementUrl].map(
(url) => new URL(url).pathname,
));
const pages = [];
for (const url of [reserveUrl, settlementUrl]) {
const page = await fetchText(fetchFn, url, {
policy: SOURCE_POLICIES.safe,
budget: safeBudget,
assertTargetAllowed: (target) => assertRobotsAllowed(robots.text, [target.pathname]),
onRedirect: (state) => { safeRedirectBehavior = state; },
});
pages.push(page);
}
observations.push(
parseSafeReserveRelease(pages[0].text, {
retrievalTime: checkedAt,
sourceUrl: pages[0].url,
previousObservation: previousById.get('safe_fx_reserves'),
}),
parseSafeSettlementRelease(pages[1].text, {
retrievalTime: checkedAt,
sourceUrl: pages[1].url,
previousObservation: previousById.get('safe_bank_fx_settlement'),
}),
);
record(sourceDecision({
...safeDecisionBase,
robotsStatus: safeRobotsStatus,
status: 'accepted',
reason: 'OK',
redirectBehavior: safeRedirectBehavior,
requestCount: safeBudget.count,
sourceUrl: listing.url,
}));
} catch (error) {
safeError = error;
if (safeRobotsStatus === 'unknown') safeRobotsStatus = 'unavailable';
record(sourceDecision({
...safeDecisionBase,
robotsStatus: safeRobotsStatus,
status: 'blocked',
reason: reasonFor(error),
redirectBehavior: safeRedirectBehavior,
requestCount: safeBudget.count,
}));
}
let pbocReason = 'SOURCE_CONTRACT_NOT_LAUNCHED';
let pbocRobotsStatus = 'unknown';
const pbocBudget = requestBudget(PBOC_MAX_REQUESTS_PER_RUN);
let pbocRedirectBehavior = 'none';
try {
const robots = await fetchText(fetchFn, PBOC_ROBOTS_URL, {
policy: SOURCE_POLICIES.pboc,
budget: pbocBudget,
onRedirect: (state) => { pbocRedirectBehavior = state; },
});
if (robotsDisallowAll(robots.text)) {
pbocReason = 'ROBOTS_DISALLOW';
pbocRobotsStatus = 'disallow_all';
} else {
pbocRobotsStatus = 'allows_candidate_paths';
}
} catch (error) {
pbocReason = reasonFor(error);
pbocRobotsStatus = 'unavailable';
}
record(sourceDecision({
publisherId: CHINA_MACRO_PUBLISHER_IDS.pboc,
source: 'People’s Bank of China',
host: 'www.pbc.gov.cn',
status: 'blocked',
reason: pbocReason,
checkedAt,
redirectBehavior: pbocRedirectBehavior,
requestBudget: PBOC_MAX_REQUESTS_PER_RUN,
requestCount: pbocBudget.count,
robotsStatus: pbocRobotsStatus,
termsStatus: pbocReason === 'ROBOTS_DISALLOW' ? 'not_evaluated_robots_blocked' : 'review_required',
sourceUrl: 'https://www.pbc.gov.cn/',
}));
observations.push(
...UNAVAILABLE_DEFINITIONS
.filter((definition) => definition.seriesId.startsWith('pboc_'))
.map((definition) => unavailableObservation(definition, pbocReason)),
);
let gaccReason = 'SOURCE_CONTRACT_NOT_LAUNCHED';
let gaccRobotsStatus = 'unknown';
const gaccBudget = requestBudget(GACC_MAX_REQUESTS_PER_RUN);
let gaccRedirectBehavior = 'none';
try {
const robots = await fetchText(fetchFn, GACC_ROBOTS_URL, {
policy: SOURCE_POLICIES.gacc,
budget: gaccBudget,
onRedirect: (state) => { gaccRedirectBehavior = state; },
});
gaccRobotsStatus = robotsDisallowAll(robots.text) ? 'disallow_all' : 'no_disallow_all_rule';
if (gaccRobotsStatus === 'disallow_all') gaccReason = 'ROBOTS_DISALLOW';
} catch (error) {
gaccReason = reasonFor(error);
gaccRobotsStatus = gaccReason === 'TLS_CERTIFICATE_ERROR' ? 'unavailable_tls' : 'unavailable';
}
record(sourceDecision({
publisherId: CHINA_MACRO_PUBLISHER_IDS.gacc,
source: 'General Administration of Customs of China',
host: 'english.customs.gov.cn',
status: 'blocked',
reason: gaccReason,
checkedAt,
redirectBehavior: gaccRedirectBehavior,
requestBudget: GACC_MAX_REQUESTS_PER_RUN,
requestCount: gaccBudget.count,
robotsStatus: gaccRobotsStatus,
termsStatus: 'reviewed_all_rights_reserved_chinese_authoritative',
sourceUrl: 'https://english.customs.gov.cn/',
}));
observations.push(
...UNAVAILABLE_DEFINITIONS
.filter((definition) => definition.seriesId.startsWith('gacc_'))
.map((definition) => unavailableObservation(definition, gaccReason)),
);
observations.push(
...preserveFailedRequiredSource({
source: 'NBS',
error: nbsError,
seriesIds: CHINA_MACRO_REQUIRED_SERIES.filter((seriesId) => seriesId.startsWith('nbs_')),
previousById,
checkedAt,
}),
...preserveFailedRequiredSource({
source: 'SAFE',
error: safeError,
seriesIds: CHINA_MACRO_REQUIRED_SERIES.filter((seriesId) => seriesId.startsWith('safe_')),
previousById,
checkedAt,
}),
);
return buildChinaMacroSnapshot({
observations,
sourceDecisions: decisions,
generatedAt: checkedAt,
});
}