From 1c6ea4556e384f14df3f14b0d83b9fb4807ac95a Mon Sep 17 00:00:00 2001 From: xuxiaowei Date: Fri, 31 Jul 2026 16:49:26 +0800 Subject: [PATCH 1/4] feat(international-organization-data): add WHO and FAO data connector Signed-off-by: xuxiaowei --- .../international-organization-data.test.mjs | 45 ++ .../assets/icon.png | Bin 0 -> 12752 bytes international-organization-data/ghost.json | 179 +++++++ .../locales/en.json | 16 + .../locales/ja.json | 16 + .../locales/ko.json | 16 + .../locales/zh-CN.json | 16 + international-organization-data/main.js | 438 ++++++++++++++++++ international-organization-data/settings.html | 82 ++++ international-organization-data/settings.js | 90 ++++ provisioning.json | 5 + 11 files changed, 903 insertions(+) create mode 100644 .tests/international-organization-data.test.mjs create mode 100644 international-organization-data/assets/icon.png create mode 100644 international-organization-data/ghost.json create mode 100644 international-organization-data/locales/en.json create mode 100644 international-organization-data/locales/ja.json create mode 100644 international-organization-data/locales/ko.json create mode 100644 international-organization-data/locales/zh-CN.json create mode 100644 international-organization-data/main.js create mode 100644 international-organization-data/settings.html create mode 100644 international-organization-data/settings.js diff --git a/.tests/international-organization-data.test.mjs b/.tests/international-organization-data.test.mjs new file mode 100644 index 0000000..5ce1e4f --- /dev/null +++ b/.tests/international-organization-data.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve('international-organization-data'); +const manifest = JSON.parse(fs.readFileSync(path.join(root, 'ghost.json'), 'utf8')); + +test('international organization data manifest is read-only and allowlisted', () => { + assert.equal(manifest.id, 'international-organization-data'); + assert.deepEqual(manifest.slots, ['tool', 'network']); + assert.deepEqual(manifest.network.hosts, [ + 'ghoapi.azureedge.net', + 'faostatservices.fao.org', + 'www.fao.org', + ]); + assert.equal(manifest.network.secrets.length, 1); + assert.equal(manifest.network.secrets[0].key, 'faostat_api_token'); + assert.equal(manifest.network.secrets[0].inject.hosts[0], 'faostatservices.fao.org'); + assert.equal(manifest.tools.length, 3); + assert.ok(manifest.tools.every((tool) => !/send|delete|write|update|create/i.test(tool.description))); +}); + +test('international organization data includes all four locale resources', () => { + for (const locale of ['en', 'zh-CN', 'ja', 'ko']) { + const file = path.join(root, 'locales', `${locale}.json`); + const resource = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(resource.name.length > 0, true); + for (const tool of ['international_org_catalog', 'who_health_data', 'fao_agriculture_data']) { + assert.equal(typeof resource.tools[tool].description, 'string'); + assert.ok(resource.tools[tool].description.length > 20); + } + } +}); + +test('international organization data does not contain credentials or arbitrary network calls', () => { + const source = fs.readFileSync(path.join(root, 'main.js'), 'utf8'); + assert.equal(source.includes('globalThis.fetch'), false); + assert.equal(source.includes('window.fetch'), false); + assert.equal(source.includes('process.env'), false); + assert.equal(source.includes('FAO_TOKEN'), false); + assert.equal(source.includes('Authorization'), true); + assert.equal(source.includes('ghoapi.azureedge.net'), true); + assert.equal(source.includes('faostatservices.fao.org'), true); +}); diff --git a/international-organization-data/assets/icon.png b/international-organization-data/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..95a6201c3c09680f740c00d94de6c13dc0f5d460 GIT binary patch literal 12752 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 literal 0 HcmV?d00001 diff --git a/international-organization-data/ghost.json b/international-organization-data/ghost.json new file mode 100644 index 0000000..fccd2eb --- /dev/null +++ b/international-organization-data/ghost.json @@ -0,0 +1,179 @@ +{ + "schemaVersion": 2, + "id": "international-organization-data", + "name": "International Organization Data", + "description": "查询 WHO 与 FAO 的官方健康、农业、粮食与食品安全统计数据。WHO 公共数据无需 API Key;FAOSTAT 数据需要在插件设置页配置官方 API Token。插件只读,不修改任何外部数据。", + "whenToUse": "需要查询 WHO 全球健康指标,或 FAO 农业、粮食、食品安全、贸易与生产统计时使用。结果会保留数据源、指标、单位、统计年份与更新时间;它不是实时监测、个人医疗诊断或投资建议工具。", + "version": "0.1.0", + "locales": { + "en": "locales/en.json", + "zh-CN": "locales/zh-CN.json", + "ja": "locales/ja.json", + "ko": "locales/ko.json" + }, + "author": "Cindy", + "icon": "assets/icon.png", + "entry": "main.js", + "settingsHtml": "settings.html", + "slots": [ + "tool", + "network" + ], + "command": "international-organization-data", + "network": { + "hosts": [ + "ghoapi.azureedge.net", + "faostatservices.fao.org", + "www.fao.org" + ], + "secrets": [ + { + "key": "faostat_api_token", + "label": "FAOSTAT API Token", + "hint": "可选;用于查询 FAOSTAT 数据", + "url": "https://www.fao.org/faostat/en/#developer-portal", + "inject": { + "header": "Authorization", + "format": "Bearer {value}", + "hosts": [ + "faostatservices.fao.org" + ] + } + } + ] + }, + "tools": [ + { + "name": "international_org_catalog", + "description": "浏览 WHO 或 FAO 的官方数据目录。source=who 时可搜索健康指标或列出国家;source=fao 时可列出数据组、数据域、维度和过滤代码。FAO 目录操作需要先在插件设置页配置 API Token。", + "parameters": { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": [ + "who", + "fao" + ], + "description": "数据源:who 或 fao" + }, + "action": { + "type": "string", + "enum": [ + "common", + "indicators", + "countries", + "groups", + "domains", + "dimensions", + "codes" + ], + "description": "WHO 支持 common、indicators、countries;FAO 支持 groups、domains、dimensions、codes。默认 common。" + }, + "query": { + "type": "string", + "description": "WHO 指标或国家搜索词;不传时返回常用目录" + }, + "groupCode": { + "type": "string", + "description": "FAO action=domains 时的数据组代码,例如 Q、T" + }, + "domainCode": { + "type": "string", + "description": "FAO action=dimensions/codes 时的数据域代码,例如 QCL、FS、T" + }, + "dimension": { + "type": "string", + "description": "FAO action=codes 时的维度,例如 area、item、element、year" + }, + "limit": { + "type": "number", + "description": "最多返回多少项,默认 20,最大 100" + } + }, + "required": [ + "source" + ] + } + }, + { + "name": "who_health_data", + "description": "查询 WHO 全球健康指标。indicator 可传 WHO 指标代码或常用别名;countries 使用 ISO3 国家代码;可选年份范围。默认优先返回总体维度,结果带国家、年份、数值、单位、原始值与 WHO 更新时间。只读,无需 API Key。健康统计仅供信息参考,不用于个人诊断。", + "parameters": { + "type": "object", + "properties": { + "indicator": { + "type": "string", + "description": "WHO 指标代码或常用别名,例如 life_expectancy、under5_mortality、maternal_mortality" + }, + "countries": { + "type": "array", + "items": { + "type": "string" + }, + "description": "可选,ISO3 国家代码数组,最多 25 个,例如 [\"CHN\", \"USA\"]" + }, + "startYear": { + "type": "number", + "description": "可选,起始年份" + }, + "endYear": { + "type": "number", + "description": "可选,结束年份" + }, + "recent": { + "type": "number", + "description": "不传年份范围时,每个国家返回最近多少期有效值,默认 1,最大 20" + }, + "limit": { + "type": "number", + "description": "最多返回多少条,默认 50,最大 200" + } + }, + "required": [ + "indicator" + ] + } + }, + { + "name": "fao_agriculture_data", + "description": "查询 FAOSTAT 农业、粮食、食品安全、贸易等官方统计。先用 international_org_catalog 查询数据域、维度和代码,再传入 domainCode 及 area/item/element/year 过滤条件。FAOSTAT 需要在插件设置页配置官方 API Token;必须至少提供一个过滤条件以避免返回过大结果。只读。", + "parameters": { + "type": "object", + "properties": { + "domainCode": { + "type": "string", + "description": "FAO 数据域代码,例如 QCL(作物与畜牧产品)、FS(食品安全)、T(贸易)" + }, + "area": { + "type": "string", + "description": "国家/地区代码,逗号分隔;先通过 catalog action=codes 查询 area 代码" + }, + "item": { + "type": "string", + "description": "商品或指标项目代码,逗号分隔;先查询 item 代码" + }, + "element": { + "type": "string", + "description": "统计要素过滤代码,逗号分隔;先查询 element 代码" + }, + "year": { + "type": "string", + "description": "年份代码,逗号分隔,例如 2020,2021,2022" + }, + "limit": { + "type": "number", + "description": "最多返回多少条,默认 50,最大 200" + }, + "showCodes": { + "type": "boolean", + "description": "是否包含 FAO 代码字段,默认 false" + } + }, + "required": [ + "domainCode" + ] + } + } + ] +} diff --git a/international-organization-data/locales/en.json b/international-organization-data/locales/en.json new file mode 100644 index 0000000..7294fc0 --- /dev/null +++ b/international-organization-data/locales/en.json @@ -0,0 +1,16 @@ +{ + "name": "International Organization Data", + "description": "Query official WHO and FAO health, agriculture, food, and food-security statistics. WHO needs no API key; FAOSTAT requires an official API token configured in the plugin settings.", + "whenToUse": "Use when the user asks for WHO health indicators or FAO agriculture, food-security, trade, and production statistics. Results include the source, indicator, unit, statistical year, and update time; this is not a personal medical diagnosis tool.", + "tools": { + "international_org_catalog": { + "description": "Browse official WHO or FAO data catalogs. WHO supports common indicators, health indicators, and countries; FAO supports groups, domains, dimensions, and filter codes. FAO catalog actions require an API token." + }, + "who_health_data": { + "description": "Query WHO global health indicators by WHO indicator code or common alias, ISO3 country codes, and optional year range. Returns country, year, numeric/display values, dimensions, and update time. No API key is required; informational only, not personal medical diagnosis." + }, + "fao_agriculture_data": { + "description": "Query official FAOSTAT agriculture, food, food-security, trade, and production statistics. Discover domains and filter codes first; configure an official API token in plugin settings and provide at least one filter. Read-only." + } + } +} diff --git a/international-organization-data/locales/ja.json b/international-organization-data/locales/ja.json new file mode 100644 index 0000000..284b5f3 --- /dev/null +++ b/international-organization-data/locales/ja.json @@ -0,0 +1,16 @@ +{ + "name": "国際機関公式データ", + "description": "WHO と FAO の公式な健康、農業、食料、食料安全保障統計を検索します。WHO は API キー不要、FAOSTAT はプラグイン設定で公式 API トークンを設定します。", + "whenToUse": "WHO の健康指標、または FAO の農業、食料、食料安全保障、貿易、生産統計を調べるときに使用します。結果にはデータソース、指標、単位、統計年、更新時刻を含めます。個人の診断には使用しません。", + "tools": { + "international_org_catalog": { + "description": "WHO または FAO の公式データカタログを参照します。WHO は一般的な指標、健康指標、国を、FAO はグループ、ドメイン、ディメンション、フィルターコードを扱います。FAO には API トークンが必要です。" + }, + "who_health_data": { + "description": "WHO の健康指標を指標コードまたは別名、ISO3 国コード、任意の年範囲で検索します。国、年、数値、表示値、ディメンション、更新時刻を返します。API キー不要で、情報提供のみです。" + }, + "fao_agriculture_data": { + "description": "FAOSTAT の農業、食料、食料安全保障、貿易、生産統計を検索します。先にドメインとフィルターコードを確認し、設定で公式 API トークンを指定してから、少なくとも一つのフィルターを渡します。読み取り専用です。" + } + } +} diff --git a/international-organization-data/locales/ko.json b/international-organization-data/locales/ko.json new file mode 100644 index 0000000..fb26776 --- /dev/null +++ b/international-organization-data/locales/ko.json @@ -0,0 +1,16 @@ +{ + "name": "국제기구 공식 데이터", + "description": "WHO와 FAO의 공식 보건, 농업, 식량 및 식량안보 통계를 조회합니다. WHO는 API 키가 필요 없고 FAOSTAT은 플러그인 설정에서 공식 API 토큰을 설정해야 합니다.", + "whenToUse": "WHO 보건 지표 또는 FAO 농업, 식량안보, 무역, 생산 통계를 조회할 때 사용합니다. 결과에는 출처, 지표, 단위, 통계 연도와 업데이트 시간이 포함되며 개인 의료 진단에는 사용하지 않습니다.", + "tools": { + "international_org_catalog": { + "description": "WHO 또는 FAO 공식 데이터 카탈로그를 탐색합니다. WHO는 공통 지표, 보건 지표와 국가를 지원하고 FAO는 그룹, 도메인, 차원과 필터 코드를 지원합니다. FAO 카탈로그에는 API 토큰이 필요합니다." + }, + "who_health_data": { + "description": "WHO 글로벌 보건 지표를 WHO 코드 또는 별칭, ISO3 국가 코드와 선택적 연도 범위로 조회합니다. 국가, 연도, 수치, 표시값, 차원과 업데이트 시간을 반환합니다. API 키가 필요 없으며 정보 제공용입니다." + }, + "fao_agriculture_data": { + "description": "FAOSTAT 공식 농업, 식량, 식량안보, 무역 및 생산 통계를 조회합니다. 먼저 도메인과 필터 코드를 확인하고 설정에서 공식 API 토큰을 구성한 뒤 하나 이상의 필터를 전달해야 합니다. 읽기 전용입니다." + } + } +} diff --git a/international-organization-data/locales/zh-CN.json b/international-organization-data/locales/zh-CN.json new file mode 100644 index 0000000..484e35b --- /dev/null +++ b/international-organization-data/locales/zh-CN.json @@ -0,0 +1,16 @@ +{ + "name": "国际组织官方数据", + "description": "查询 WHO 与 FAO 的官方健康、农业、粮食与食品安全统计数据。WHO 无需 API Key;FAOSTAT 需要在插件设置页配置官方 API Token。", + "whenToUse": "需要查询 WHO 全球健康指标,或 FAO 农业、粮食、食品安全、贸易与生产统计时使用。结果带数据源、指标、单位、统计年份与更新时间;不用于个人医疗诊断。", + "tools": { + "international_org_catalog": { + "description": "浏览 WHO 或 FAO 的官方数据目录。WHO 支持常用指标、健康指标和国家目录;FAO 支持数据组、数据域、维度和过滤代码。FAO 目录需要先配置 API Token。" + }, + "who_health_data": { + "description": "查询 WHO 全球健康指标。可使用 WHO 指标代码或常用别名,并按 ISO3 国家代码和年份筛选。返回国家、年份、数值、原始值、维度和更新时间。无需 API Key,仅供信息参考,不用于个人医疗诊断。" + }, + "fao_agriculture_data": { + "description": "查询 FAOSTAT 农业、粮食、食品安全、贸易等官方统计。先查数据域和过滤代码,再传入筛选条件;需要在插件设置页配置官方 API Token,且至少提供一个过滤条件。只读。" + } + } +} diff --git a/international-organization-data/main.js b/international-organization-data/main.js new file mode 100644 index 0000000..b4fdf66 --- /dev/null +++ b/international-organization-data/main.js @@ -0,0 +1,438 @@ +/* global cindy */ + +/** + * International Organization Data + * + * WHO uses its public GHO OData API and needs no credential. + * FAOSTAT uses the official API and receives its Bearer token only through the + * network.secrets declaration in ghost.json. The token never enters plugin + * code, tool arguments, result objects, or logs. + */ + +var WHO_BASE = 'https://ghoapi.azureedge.net/api'; +var FAO_BASE = 'https://faostatservices.fao.org/api/v1'; +var MAX_COUNTRIES = 25; +var MAX_LIMIT = 200; + +var WHO_ALIASES = { + life_expectancy: { + code: 'WHOSIS_000001', + name: 'Life expectancy at birth' + }, + life_expectancy_at_birth: { + code: 'WHOSIS_000001', + name: 'Life expectancy at birth' + }, + under5_mortality: { + code: 'MDG_0000000007', + name: 'Under-five mortality rate' + }, + maternal_mortality: { + code: 'MDG_0000000026', + name: 'Maternal mortality ratio' + }, + tuberculosis: { + code: 'MDG_0000000020', + name: 'Incidence of tuberculosis' + }, + malaria: { + code: 'MALARIA_EST_CASES', + name: 'Malaria cases' + } +}; + +var WHO_COMMON_INDICATORS = Object.keys(WHO_ALIASES).map(function (alias) { + return { + alias: alias, + code: WHO_ALIASES[alias].code, + name: WHO_ALIASES[alias].name + }; +}); + +function clampNumber(value, fallback, min, max) { + var n = typeof value === 'number' && isFinite(value) ? Math.floor(value) : fallback; + return Math.min(max, Math.max(min, n)); +} + +function quoteOData(value) { + return String(value).replace(/'/g, "''"); +} + +function buildQuery(parts) { + return parts.filter(Boolean).join(' and '); +} + +function truncateMessage(value) { + return String(value || '').replace(/\s+/g, ' ').slice(0, 300); +} + +async function getJson(url) { + var response; + try { + response = await cindy.fetch({ + url: url, + headers: { Accept: 'application/json' } + }); + } catch (error) { + return { + ok: false, + message: '请求失败:' + truncateMessage(error && error.message ? error.message : error) + }; + } + if (!response || !response.ok) { + return { + ok: false, + message: response && response.message + ? response.message + : '请求失败' + }; + } + if (response.status < 200 || response.status >= 300) { + var errorText = response.body ? truncateMessage(response.body) : ''; + return { + ok: false, + message: '上游返回 HTTP ' + response.status + (errorText ? ':' + errorText : '') + }; + } + try { + return { ok: true, data: JSON.parse(response.body || '{}') }; + } catch (error) { + return { ok: false, message: '上游返回了无法解析的 JSON' }; + } +} + +function envelopeRows(data) { + if (Array.isArray(data)) return data; + if (data && Array.isArray(data.value)) return data.value; + if (data && Array.isArray(data.data)) return data.data; + return []; +} + +function normalizeCountries(args) { + var countries = args && Array.isArray(args.countries) ? args.countries : []; + return countries + .filter(function (item) { return typeof item === 'string'; }) + .map(function (item) { return item.trim().toUpperCase(); }) + .filter(Boolean) + .slice(0, MAX_COUNTRIES); +} + +function resolveWhoIndicator(value) { + var input = typeof value === 'string' ? value.trim() : ''; + if (!input) return null; + var alias = WHO_ALIASES[input.toLowerCase()]; + return alias || { code: input, name: input }; +} + +function isoDate(value) { + return typeof value === 'string' && value ? value : null; +} + +async function whoCatalog(args) { + var action = args.action || 'common'; + var limit = clampNumber(args.limit, 20, 1, 100); + var query = typeof args.query === 'string' ? args.query.trim() : ''; + + if (action === 'common') { + return { + ok: true, + source: 'WHO', + action: 'common', + indicators: WHO_COMMON_INDICATORS, + note: '传 action=indicators 搜索 WHO 完整指标目录,传 action=countries 查询国家代码。' + }; + } + + if (action === 'countries') { + var countryUrl = WHO_BASE + '/Dimension/COUNTRY/DimensionValues?$top=' + limit + '&$format=json'; + if (query) { + countryUrl += '&$filter=contains(Title,%27' + encodeURIComponent(quoteOData(query)) + '%27)'; + } + var countryResult = await getJson(countryUrl); + if (!countryResult.ok) return countryResult; + return { + ok: true, + source: 'WHO', + action: 'countries', + countries: envelopeRows(countryResult.data).map(function (row) { + return { + code: row.Code, + name: row.Title, + regionCode: row.ParentCode, + region: row.ParentTitle + }; + }) + }; + } + + if (action !== 'indicators') { + return { ok: false, message: 'WHO 目录 action 只支持 common、indicators、countries' }; + } + + var indicatorUrl = WHO_BASE + '/Indicator?$top=' + limit + '&$format=json'; + if (query) { + indicatorUrl += '&$filter=contains(IndicatorName,%27' + encodeURIComponent(quoteOData(query)) + '%27)'; + } + var indicatorResult = await getJson(indicatorUrl); + if (!indicatorResult.ok) return indicatorResult; + return { + ok: true, + source: 'WHO', + action: 'indicators', + indicators: envelopeRows(indicatorResult.data).map(function (row) { + return { + code: row.IndicatorCode, + name: row.IndicatorName, + language: row.Language + }; + }) + }; +} + +function faoMissingToken(result) { + return !result.ok && typeof result.message === 'string' + && (result.message.indexOf('401') >= 0 + || result.message.indexOf('Authorization') >= 0 + || result.message.indexOf('尚未配置') >= 0); +} + +async function faoGet(path, params) { + var query = Object.keys(params || {}) + .filter(function (key) { + return params[key] !== undefined && params[key] !== null && params[key] !== ''; + }) + .map(function (key) { + return encodeURIComponent(key) + '=' + encodeURIComponent(String(params[key])); + }) + .join('&'); + var result = await getJson(FAO_BASE + path + (query ? '?' + query : '')); + if (faoMissingToken(result)) { + return { + ok: false, + message: 'FAOSTAT 需要 API Token。请在插件设置页配置官方 FAOSTAT API Token 后重试。' + }; + } + return result; +} + +async function faoCatalog(args) { + var action = args.action || 'groups'; + var lang = 'en'; + var result; + + if (action === 'groups') { + result = await faoGet('/' + lang + '/groupsanddomains'); + } else if (action === 'domains') { + if (!args.groupCode) return { ok: false, message: 'FAO action=domains 需要 groupCode' }; + result = await faoGet('/' + lang + '/domains/' + encodeURIComponent(args.groupCode) + '/', {}); + } else if (action === 'dimensions') { + if (!args.domainCode) return { ok: false, message: 'FAO action=dimensions 需要 domainCode' }; + result = await faoGet('/' + lang + '/dimensions/' + encodeURIComponent(args.domainCode) + '/', {}); + } else if (action === 'codes') { + if (!args.domainCode || !args.dimension) { + return { ok: false, message: 'FAO action=codes 需要 domainCode 和 dimension' }; + } + result = await faoGet( + '/' + lang + '/codes/' + encodeURIComponent(args.dimension) + '/' + encodeURIComponent(args.domainCode), + {} + ); + } else { + return { ok: false, message: 'FAO 目录 action 只支持 groups、domains、dimensions、codes' }; + } + if (!result.ok) return result; + + var rows = envelopeRows(result.data); + var limit = clampNumber(args.limit, 20, 1, 100); + return { + ok: true, + source: 'FAO', + action: action, + data: rows.slice(0, limit), + total: rows.length + }; +} + +async function internationalOrgCatalog(args) { + var source = args && typeof args.source === 'string' ? args.source.toLowerCase() : ''; + if (source === 'who') return whoCatalog(args || {}); + if (source === 'fao') return faoCatalog(args || {}); + return { ok: false, message: 'source 必须是 who 或 fao' }; +} + +function whoFilter(countries, startYear, endYear, overallOnly) { + var countryPart = countries.length === 1 + ? "SpatialDim eq '" + quoteOData(countries[0]) + "'" + : countries.length + ? '(' + countries.map(function (country) { + return "SpatialDim eq '" + quoteOData(country) + "'"; + }).join(' or ') + ')' + : ''; + var parts = [countryPart]; + if (typeof startYear === 'number') parts.push('TimeDim ge ' + Math.floor(startYear)); + if (typeof endYear === 'number') parts.push('TimeDim le ' + Math.floor(endYear)); + if (overallOnly) parts.push("Dim1 eq 'SEX_BTSX'"); + return buildQuery(parts); +} + +function normalizeWhoRows(rows, indicator) { + return rows.map(function (row) { + return { + indicator: row.IndicatorCode || indicator.code, + indicatorName: indicator.name, + country: row.SpatialDim || null, + year: row.TimeDim || null, + value: row.NumericValue === undefined ? null : row.NumericValue, + displayValue: row.Value === undefined ? null : row.Value, + low: row.Low === undefined ? null : row.Low, + high: row.High === undefined ? null : row.High, + unit: row.Dim1Type || null, + dimension: row.Dim1 || null, + sourceUpdatedAt: isoDate(row.Date), + source: 'WHO GHO' + }; + }); +} + +async function whoHealthData(args) { + var indicator = resolveWhoIndicator(args && args.indicator); + if (!indicator) return { ok: false, message: 'indicator 不能为空;可先调用 international_org_catalog(source=who, action=common)' }; + + var countries = normalizeCountries(args || {}); + var startYear = typeof args.startYear === 'number' ? args.startYear : null; + var endYear = typeof args.endYear === 'number' ? args.endYear : null; + if ((startYear === null) !== (endYear === null)) { + return { ok: false, message: 'startYear 和 endYear 必须同时传入' }; + } + if (startYear !== null && (startYear < 1900 || endYear > 2200 || startYear > endYear)) { + return { ok: false, message: '年份范围无效' }; + } + + var limit = clampNumber(args.limit, 50, 1, MAX_LIMIT); + var url = WHO_BASE + '/' + encodeURIComponent(indicator.code) + + '?$top=' + limit + + '&$format=json'; + var filter = whoFilter(countries, startYear, endYear, true); + if (filter) url += '&$filter=' + encodeURIComponent(filter); + var result = await getJson(url); + if (!result.ok) return result; + + var rows = envelopeRows(result.data); + if (!rows.length) { + var fallbackUrl = WHO_BASE + '/' + encodeURIComponent(indicator.code) + + '?$top=' + limit + '&$format=json'; + var fallbackFilter = whoFilter(countries, startYear, endYear, false); + if (fallbackFilter) fallbackUrl += '&$filter=' + encodeURIComponent(fallbackFilter); + var fallback = await getJson(fallbackUrl); + if (!fallback.ok) return fallback; + rows = envelopeRows(fallback.data); + } + var normalized = normalizeWhoRows(rows, indicator); + if (startYear === null && normalized.length) { + var byCountry = {}; + normalized.forEach(function (row) { + var key = row.country || '_unknown'; + if (!byCountry[key]) byCountry[key] = []; + byCountry[key].push(row); + }); + var recent = clampNumber(args.recent, 1, 1, 20); + normalized = Object.keys(byCountry).reduce(function (acc, key) { + return acc.concat(byCountry[key].sort(function (a, b) { + return (b.year || 0) - (a.year || 0); + }).slice(0, recent)); + }, []); + } + return { + ok: true, + source: 'WHO', + indicator: indicator, + countries: countries, + rows: normalized, + count: normalized.length, + note: 'WHO 健康统计数据仅供信息参考,不用于个人医疗诊断。' + }; +} + +async function faoAgricultureData(args) { + var domain = args && typeof args.domainCode === 'string' ? args.domainCode.trim().toUpperCase() : ''; + if (!domain) return { ok: false, message: 'domainCode 不能为空' }; + var filters = { + area: args.area, + element: args.element, + item: args.item, + year: args.year + }; + var hasFilter = Object.keys(filters).some(function (key) { + return typeof filters[key] === 'string' && filters[key].trim() !== ''; + }); + if (!hasFilter) { + return { ok: false, message: 'FAO 查询至少需要一个过滤条件:area、item、element 或 year' }; + } + var limit = clampNumber(args.limit, 50, 1, MAX_LIMIT); + var params = { + show_codes: Boolean(args.showCodes), + show_unit: true, + show_flags: false, + null_values: false, + output_type: 'objects', + limit: limit + }; + Object.keys(filters).forEach(function (key) { + if (typeof filters[key] === 'string' && filters[key].trim()) params[key] = filters[key].trim(); + }); + var result = await faoGet('/en/data/' + encodeURIComponent(domain) + '/', params); + if (!result.ok) return result; + var rows = envelopeRows(result.data).slice(0, limit); + return { + ok: true, + source: 'FAOSTAT', + domainCode: domain, + rows: rows, + count: rows.length, + note: 'FAOSTAT 数据来自联合国粮农组织官方统计数据库。' + }; +} + +var handlers = { + international_org_catalog: internationalOrgCatalog, + who_health_data: whoHealthData, + fao_agriculture_data: faoAgricultureData +}; + +cindy.onHostMessage(async function (message) { + if (!message || message.type !== 'tool-call') return; + var handler = handlers[message.tool]; + if (!handler) { + cindy.send({ + type: 'tool-result', + callId: message.callId, + ok: false, + message: '未知工具:' + String(message.tool) + }); + return; + } + try { + var result = await handler(message.args || {}); + if (result.ok) { + cindy.send({ + type: 'tool-result', + callId: message.callId, + ok: true, + result: result + }); + } else { + cindy.send({ + type: 'tool-result', + callId: message.callId, + ok: false, + message: result.message + }); + } + } catch (error) { + cindy.send({ + type: 'tool-result', + callId: message.callId, + ok: false, + message: '国际组织数据查询失败:' + truncateMessage(error && error.message ? error.message : error) + }); + } +}); diff --git a/international-organization-data/settings.html b/international-organization-data/settings.html new file mode 100644 index 0000000..ab70c1f --- /dev/null +++ b/international-organization-data/settings.html @@ -0,0 +1,82 @@ + + + + + + + +
+ FAOSTAT API Token(可选) + ✓ 已保存 +
+

+ WHO 数据无需配置;配置 Token 后可查询 FAOSTAT 农业、粮食与食品安全数据。 + 获取 FAOSTAT Token ↗ +

+
+
+ + +
+ + +
+

+ + + diff --git a/international-organization-data/settings.js b/international-organization-data/settings.js new file mode 100644 index 0000000..8d0a141 --- /dev/null +++ b/international-organization-data/settings.js @@ -0,0 +1,90 @@ +(function () { + 'use strict'; + + var input = document.getElementById('token'); + var eye = document.getElementById('eye'); + var badge = document.getElementById('badge'); + var status = document.getElementById('status'); + var timer = null; + + function showStatus(message) { + status.textContent = message; + if (timer) clearTimeout(timer); + timer = setTimeout(function () { status.textContent = ''; }, 2500); + } + + function syncEye() { + var empty = input.value.length === 0; + eye.classList.toggle('hidden', empty); + if (empty) { + input.type = 'password'; + eye.classList.remove('revealed'); + } + } + + function setSaved(saved, tail) { + badge.className = saved ? 'badge on' : 'badge'; + input.placeholder = saved + ? (tail ? '已保存(尾号 ' + tail + '),粘贴新值可覆盖' : '已保存,粘贴新值可覆盖') + : '粘贴 FAOSTAT API Token'; + } + + eye.addEventListener('click', function () { + var revealed = input.type === 'password'; + input.type = revealed ? 'text' : 'password'; + eye.classList.toggle('revealed', revealed); + }); + input.addEventListener('input', syncEye); + + document.getElementById('save').addEventListener('click', function () { + var value = input.value.trim(); + if (!value) { + showStatus('先粘贴 Token 再保存'); + return; + } + fetch('/secrets/faostat_api_token', { + method: 'PUT', + body: JSON.stringify({ value: value }) + }).then(function (response) { + if (response.status !== 204) throw new Error('HTTP ' + response.status); + input.value = ''; + syncEye(); + setSaved(true, null); + void load(); + showStatus('Token 已安全保存'); + }).catch(function () { + showStatus('保存失败,请重试'); + }); + }); + + document.getElementById('clear').addEventListener('click', function () { + fetch('/secrets/faostat_api_token', { method: 'DELETE' }) + .then(function (response) { + if (response.status !== 204) throw new Error('HTTP ' + response.status); + input.value = ''; + syncEye(); + setSaved(false, null); + showStatus('Token 已清除'); + }).catch(function () { + showStatus('清除失败,请重试'); + }); + }); + + async function load() { + try { + var response = await fetch('/secrets'); + var entries = await response.json(); + var entry = Array.isArray(entries) + ? entries.filter(function (item) { + return item && item.key === 'faostat_api_token'; + })[0] + : null; + setSaved(Boolean(entry && entry.saved), entry && entry.tail ? entry.tail : null); + } catch (error) { + setSaved(false, null); + } + } + + syncEye(); + void load(); +})(); diff --git a/provisioning.json b/provisioning.json index 78a7f96..84fed76 100644 --- a/provisioning.json +++ b/provisioning.json @@ -47,6 +47,11 @@ "audience": { "emails": [] } + }, + "international-organization-data": { + "audience": { + "emails": [] + } } } } From 856c5c7e008a2aaee1f35546dded643e958c7657 Mon Sep 17 00:00:00 2001 From: xuxiaowei Date: Fri, 31 Jul 2026 17:27:22 +0800 Subject: [PATCH 2/4] fix(international-organization-data): stabilize recent queries Signed-off-by: xuxiaowei --- .../international-organization-data.test.mjs | 89 +++++++++++++ international-organization-data/ghost.json | 6 +- .../locales/en.json | 2 +- .../locales/ja.json | 2 +- .../locales/ko.json | 2 +- .../locales/zh-CN.json | 2 +- international-organization-data/main.js | 125 +++++++++++++----- 7 files changed, 185 insertions(+), 43 deletions(-) diff --git a/.tests/international-organization-data.test.mjs b/.tests/international-organization-data.test.mjs index 5ce1e4f..581dc6e 100644 --- a/.tests/international-organization-data.test.mjs +++ b/.tests/international-organization-data.test.mjs @@ -2,9 +2,42 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import vm from 'node:vm'; const root = path.resolve('international-organization-data'); const manifest = JSON.parse(fs.readFileSync(path.join(root, 'ghost.json'), 'utf8')); +const runtimeSource = fs.readFileSync(path.join(root, 'main.js'), 'utf8'); + +function loadRuntime(fetchImpl) { + let handler; + const messages = []; + vm.runInNewContext(runtimeSource, { + isFinite, + encodeURIComponent, + String, + Math, + JSON, + Date, + Promise, + setTimeout, + cindy: { + onHostMessage(callback) { + handler = callback; + }, + send(message) { + messages.push(message); + }, + fetch: fetchImpl, + }, + }); + return { + async call(tool, args) { + messages.length = 0; + await handler({ type: 'tool-call', callId: tool, tool, args }); + return messages[0]; + }, + }; +} test('international organization data manifest is read-only and allowlisted', () => { assert.equal(manifest.id, 'international-organization-data'); @@ -43,3 +76,59 @@ test('international organization data does not contain credentials or arbitrary assert.equal(source.includes('ghoapi.azureedge.net'), true); assert.equal(source.includes('faostatservices.fao.org'), true); }); + +test('WHO recent queries are ordered and scoped per country', () => { + const requestedUrls = []; + const runtime = loadRuntime(async ({ url }) => { + requestedUrls.push(decodeURIComponent(url)); + const country = url.includes('CHN') ? 'CHN' : 'USA'; + return { + ok: true, + status: 200, + body: JSON.stringify({ + value: [{ + IndicatorCode: 'WHOSIS_000001', + SpatialDim: country, + TimeDim: 2021, + NumericValue: country === 'CHN' ? 77.6 : 76.4, + Value: country === 'CHN' ? '77.6' : '76.4', + Dim1Type: 'SEX', + Dim1: 'SEX_BTSX', + }], + }), + }; + }); + + return runtime.call('who_health_data', { + indicator: 'life_expectancy', + countries: ['CHN', 'USA'], + recent: 1, + limit: 1, + }).then((result) => { + assert.equal(result.ok, true); + assert.deepEqual( + Array.from(result.result.rows, (row) => row.country), + ['CHN', 'USA'], + ); + assert.equal(requestedUrls.length, 2); + assert.ok(requestedUrls.every((url) => url.includes('$orderby=TimeDim desc'))); + assert.equal(requestedUrls.filter((url) => url.includes("SpatialDim eq 'CHN'")).length, 1); + assert.equal(requestedUrls.filter((url) => url.includes("SpatialDim eq 'USA'")).length, 1); + }); +}); + +test('FAOSTAT auth failures have actionable guidance', async () => { + const runtime = loadRuntime(async () => ({ + ok: false, + status: 403, + body: '{"message":"Forbidden"}', + message: 'HTTP 403', + })); + const result = await runtime.call('fao_agriculture_data', { + domainCode: 'QCL', + area: '351', + }); + assert.equal(result.ok, false); + assert.match(result.message, /HTTP 403/); + assert.match(result.message, /检查 Token 是否有效、权限是否已开通/); +}); diff --git a/international-organization-data/ghost.json b/international-organization-data/ghost.json index fccd2eb..2aefb37 100644 --- a/international-organization-data/ghost.json +++ b/international-organization-data/ghost.json @@ -98,7 +98,7 @@ }, { "name": "who_health_data", - "description": "查询 WHO 全球健康指标。indicator 可传 WHO 指标代码或常用别名;countries 使用 ISO3 国家代码;可选年份范围。默认优先返回总体维度,结果带国家、年份、数值、单位、原始值与 WHO 更新时间。只读,无需 API Key。健康统计仅供信息参考,不用于个人诊断。", + "description": "查询 WHO 全球健康指标。indicator 可传 WHO 指标代码或常用别名;countries 使用 ISO3 国家代码;可选年份范围。不传年份范围时必须提供 countries,插件会按国家分别查询并返回每个国家最近有效值,最终最多返回 countries 数量 × recent 条,避免全局截断遗漏国家。结果带国家、年份、数值、单位、原始值与 WHO 更新时间。只读,无需 API Key。健康统计仅供信息参考,不用于个人诊断。", "parameters": { "type": "object", "properties": { @@ -111,7 +111,7 @@ "items": { "type": "string" }, - "description": "可选,ISO3 国家代码数组,最多 25 个,例如 [\"CHN\", \"USA\"]" + "description": "ISO3 国家代码数组,最多 25 个,例如 [\"CHN\", \"USA\"];不传年份范围时必填" }, "startYear": { "type": "number", @@ -127,7 +127,7 @@ }, "limit": { "type": "number", - "description": "最多返回多少条,默认 50,最大 200" + "description": "传年份范围时是单次返回上限;不传年份时是每个国家向 WHO 请求的候选行上限,最终每国最多返回 recent 条。默认 50,最大 200" } }, "required": [ diff --git a/international-organization-data/locales/en.json b/international-organization-data/locales/en.json index 7294fc0..b0ea99d 100644 --- a/international-organization-data/locales/en.json +++ b/international-organization-data/locales/en.json @@ -7,7 +7,7 @@ "description": "Browse official WHO or FAO data catalogs. WHO supports common indicators, health indicators, and countries; FAO supports groups, domains, dimensions, and filter codes. FAO catalog actions require an API token." }, "who_health_data": { - "description": "Query WHO global health indicators by WHO indicator code or common alias, ISO3 country codes, and optional year range. Returns country, year, numeric/display values, dimensions, and update time. No API key is required; informational only, not personal medical diagnosis." + "description": "Query WHO global health indicators by WHO indicator code or common alias, ISO3 country codes, and optional year range. Without a year range, provide countries so the plugin can query each country separately and return up to countries × recent deterministic non-empty observations without global truncation. No API key is required; informational only, not personal medical diagnosis." }, "fao_agriculture_data": { "description": "Query official FAOSTAT agriculture, food, food-security, trade, and production statistics. Discover domains and filter codes first; configure an official API token in plugin settings and provide at least one filter. Read-only." diff --git a/international-organization-data/locales/ja.json b/international-organization-data/locales/ja.json index 284b5f3..c09e677 100644 --- a/international-organization-data/locales/ja.json +++ b/international-organization-data/locales/ja.json @@ -7,7 +7,7 @@ "description": "WHO または FAO の公式データカタログを参照します。WHO は一般的な指標、健康指標、国を、FAO はグループ、ドメイン、ディメンション、フィルターコードを扱います。FAO には API トークンが必要です。" }, "who_health_data": { - "description": "WHO の健康指標を指標コードまたは別名、ISO3 国コード、任意の年範囲で検索します。国、年、数値、表示値、ディメンション、更新時刻を返します。API キー不要で、情報提供のみです。" + "description": "WHO の健康指標を指標コードまたは別名、ISO3 国コード、任意の年範囲で検索します。年範囲を指定しない場合は国コードが必要で、国ごとに別々に検索して最大で国数 × recent 件の最新の空でない観測値を返し、全体の切り捨てによる欠落を防ぎます。API キー不要で、情報提供のみです。" }, "fao_agriculture_data": { "description": "FAOSTAT の農業、食料、食料安全保障、貿易、生産統計を検索します。先にドメインとフィルターコードを確認し、設定で公式 API トークンを指定してから、少なくとも一つのフィルターを渡します。読み取り専用です。" diff --git a/international-organization-data/locales/ko.json b/international-organization-data/locales/ko.json index fb26776..1a23fc7 100644 --- a/international-organization-data/locales/ko.json +++ b/international-organization-data/locales/ko.json @@ -7,7 +7,7 @@ "description": "WHO 또는 FAO 공식 데이터 카탈로그를 탐색합니다. WHO는 공통 지표, 보건 지표와 국가를 지원하고 FAO는 그룹, 도메인, 차원과 필터 코드를 지원합니다. FAO 카탈로그에는 API 토큰이 필요합니다." }, "who_health_data": { - "description": "WHO 글로벌 보건 지표를 WHO 코드 또는 별칭, ISO3 국가 코드와 선택적 연도 범위로 조회합니다. 국가, 연도, 수치, 표시값, 차원과 업데이트 시간을 반환합니다. API 키가 필요 없으며 정보 제공용입니다." + "description": "WHO 글로벌 보건 지표를 WHO 코드 또는 별칭, ISO3 국가 코드와 선택적 연도 범위로 조회합니다. 연도 범위를 생략하면 국가 코드를 제공해야 하며 국가별로 따로 조회해 최대 국가 수 × recent개의 최신 비어 있지 않은 관측값을 반환하고 전체 잘림으로 국가가 누락되지 않도록 합니다. API 키가 필요 없으며 정보 제공용입니다." }, "fao_agriculture_data": { "description": "FAOSTAT 공식 농업, 식량, 식량안보, 무역 및 생산 통계를 조회합니다. 먼저 도메인과 필터 코드를 확인하고 설정에서 공식 API 토큰을 구성한 뒤 하나 이상의 필터를 전달해야 합니다. 읽기 전용입니다." diff --git a/international-organization-data/locales/zh-CN.json b/international-organization-data/locales/zh-CN.json index 484e35b..9867b99 100644 --- a/international-organization-data/locales/zh-CN.json +++ b/international-organization-data/locales/zh-CN.json @@ -7,7 +7,7 @@ "description": "浏览 WHO 或 FAO 的官方数据目录。WHO 支持常用指标、健康指标和国家目录;FAO 支持数据组、数据域、维度和过滤代码。FAO 目录需要先配置 API Token。" }, "who_health_data": { - "description": "查询 WHO 全球健康指标。可使用 WHO 指标代码或常用别名,并按 ISO3 国家代码和年份筛选。返回国家、年份、数值、原始值、维度和更新时间。无需 API Key,仅供信息参考,不用于个人医疗诊断。" + "description": "查询 WHO 全球健康指标。可使用 WHO 指标代码或常用别名,并按 ISO3 国家代码和年份筛选。不传年份时必须提供国家代码,插件会按国家分别查询最近有效值,最终最多返回国家数 × recent 条,避免全局截断遗漏国家。无需 API Key,仅供信息参考,不用于个人医疗诊断。" }, "fao_agriculture_data": { "description": "查询 FAOSTAT 农业、粮食、食品安全、贸易等官方统计。先查数据域和过滤代码,再传入筛选条件;需要在插件设置页配置官方 API Token,且至少提供一个过滤条件。只读。" diff --git a/international-organization-data/main.js b/international-organization-data/main.js index b4fdf66..8080173 100644 --- a/international-organization-data/main.js +++ b/international-organization-data/main.js @@ -79,21 +79,26 @@ async function getJson(url) { message: '请求失败:' + truncateMessage(error && error.message ? error.message : error) }; } - if (!response || !response.ok) { + if (!response) { return { ok: false, - message: response && response.message - ? response.message - : '请求失败' + message: '请求失败' }; } - if (response.status < 200 || response.status >= 300) { + if (typeof response.status === 'number' && (response.status < 200 || response.status >= 300)) { var errorText = response.body ? truncateMessage(response.body) : ''; return { ok: false, + status: response.status, message: '上游返回 HTTP ' + response.status + (errorText ? ':' + errorText : '') }; } + if (!response.ok) { + return { + ok: false, + message: response.message || '请求失败' + }; + } try { return { ok: true, data: JSON.parse(response.body || '{}') }; } catch (error) { @@ -192,10 +197,21 @@ async function whoCatalog(args) { function faoMissingToken(result) { return !result.ok && typeof result.message === 'string' && (result.message.indexOf('401') >= 0 + || result.message.indexOf('403') >= 0 || result.message.indexOf('Authorization') >= 0 || result.message.indexOf('尚未配置') >= 0); } +function faoAuthMessage(status) { + if (status === 401) { + return 'FAOSTAT 未接受当前 API Token(HTTP 401)。请在插件设置页配置或重新保存官方 FAOSTAT API Token 后重试。'; + } + if (status === 403) { + return 'FAOSTAT 拒绝了当前 API Token(HTTP 403)。请在插件设置页检查 Token 是否有效、权限是否已开通;确认后重新保存 Token 再重试。'; + } + return 'FAOSTAT 需要 API Token。请在插件设置页配置官方 FAOSTAT API Token 后重试。'; +} + async function faoGet(path, params) { var query = Object.keys(params || {}) .filter(function (key) { @@ -207,9 +223,12 @@ async function faoGet(path, params) { .join('&'); var result = await getJson(FAO_BASE + path + (query ? '?' + query : '')); if (faoMissingToken(result)) { + var authStatus = result.status; + if (!authStatus && result.message.indexOf('403') >= 0) authStatus = 403; + if (!authStatus && result.message.indexOf('401') >= 0) authStatus = 401; return { ok: false, - message: 'FAOSTAT 需要 API Token。请在插件设置页配置官方 FAOSTAT API Token 后重试。' + message: faoAuthMessage(authStatus) }; } return result; @@ -274,6 +293,43 @@ function whoFilter(countries, startYear, endYear, overallOnly) { return buildQuery(parts); } +function whoDataUrl(indicator, countries, startYear, endYear, overallOnly, top) { + var url = WHO_BASE + '/' + encodeURIComponent(indicator.code) + + '?$top=' + top + '&$format=json'; + var filter = whoFilter(countries, startYear, endYear, overallOnly); + if (filter) url += '&$filter=' + encodeURIComponent(filter); + if (startYear === null && endYear === null) { + url += '&$orderby=' + encodeURIComponent('TimeDim desc'); + } + return url; +} + +async function fetchWhoRows(indicator, countries, startYear, endYear, top, overallOnly) { + var result = await getJson(whoDataUrl( + indicator, + countries, + startYear, + endYear, + overallOnly, + top + )); + if (!result.ok) return result; + var rows = envelopeRows(result.data); + if (!rows.length && overallOnly) { + var fallback = await getJson(whoDataUrl( + indicator, + countries, + startYear, + endYear, + false, + top + )); + if (!fallback.ok) return fallback; + rows = envelopeRows(fallback.data); + } + return { ok: true, rows: rows }; +} + function normalizeWhoRows(rows, indicator) { return rows.map(function (row) { return { @@ -308,38 +364,35 @@ async function whoHealthData(args) { } var limit = clampNumber(args.limit, 50, 1, MAX_LIMIT); - var url = WHO_BASE + '/' + encodeURIComponent(indicator.code) - + '?$top=' + limit - + '&$format=json'; - var filter = whoFilter(countries, startYear, endYear, true); - if (filter) url += '&$filter=' + encodeURIComponent(filter); - var result = await getJson(url); - if (!result.ok) return result; - - var rows = envelopeRows(result.data); - if (!rows.length) { - var fallbackUrl = WHO_BASE + '/' + encodeURIComponent(indicator.code) - + '?$top=' + limit + '&$format=json'; - var fallbackFilter = whoFilter(countries, startYear, endYear, false); - if (fallbackFilter) fallbackUrl += '&$filter=' + encodeURIComponent(fallbackFilter); - var fallback = await getJson(fallbackUrl); - if (!fallback.ok) return fallback; - rows = envelopeRows(fallback.data); + var recent = clampNumber(args.recent, 1, 1, 20); + var normalized = []; + if (startYear === null && !countries.length) { + return { + ok: false, + message: '不传 startYear/endYear 时 countries 不能为空;这样才能按国家分别查询并可靠返回最近有效值' + }; } - var normalized = normalizeWhoRows(rows, indicator); - if (startYear === null && normalized.length) { - var byCountry = {}; - normalized.forEach(function (row) { - var key = row.country || '_unknown'; - if (!byCountry[key]) byCountry[key] = []; - byCountry[key].push(row); - }); - var recent = clampNumber(args.recent, 1, 1, 20); - normalized = Object.keys(byCountry).reduce(function (acc, key) { - return acc.concat(byCountry[key].sort(function (a, b) { + if (startYear === null) { + for (var i = 0; i < countries.length; i++) { + var countryResult = await fetchWhoRows( + indicator, + [countries[i]], + null, + null, + Math.max(limit, recent), + true + ); + if (!countryResult.ok) return countryResult; + var countryRows = normalizeWhoRows(countryResult.rows, indicator); + countryRows.sort(function (a, b) { return (b.year || 0) - (a.year || 0); - }).slice(0, recent)); - }, []); + }); + normalized = normalized.concat(countryRows.slice(0, recent)); + } + } else { + var result = await fetchWhoRows(indicator, countries, startYear, endYear, limit, true); + if (!result.ok) return result; + normalized = normalizeWhoRows(result.rows, indicator); } return { ok: true, From 6981c9b7b36e198ea6ad47f479e4f3e29e43a23d Mon Sep 17 00:00:00 2001 From: xuxiaowei Date: Fri, 31 Jul 2026 17:52:42 +0800 Subject: [PATCH 3/4] fix(international-organization-data): skip empty WHO observations Signed-off-by: xuxiaowei --- .../international-organization-data.test.mjs | 30 +++++++++++++++++++ international-organization-data/main.js | 5 +++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.tests/international-organization-data.test.mjs b/.tests/international-organization-data.test.mjs index 9c87cab..7a5b87e 100644 --- a/.tests/international-organization-data.test.mjs +++ b/.tests/international-organization-data.test.mjs @@ -113,6 +113,7 @@ test('WHO recent queries are ordered and scoped per country', () => { assert.equal(requestedUrls.length, 2); assert.ok(requestedUrls.every((url) => url.includes('$orderby=TimeDim desc'))); assert.ok(requestedUrls.every((url) => url.includes("Dim1 eq 'SEX_BTSX'"))); + assert.ok(requestedUrls.every((url) => url.includes('(NumericValue ne null or Value ne null)'))); assert.equal(requestedUrls.filter((url) => url.includes("SpatialDim eq 'CHN'")).length, 1); assert.equal(requestedUrls.filter((url) => url.includes("SpatialDim eq 'USA'")).length, 1); assert.equal(Object.hasOwn(result.result.rows[0], 'unit'), false); @@ -120,6 +121,35 @@ test('WHO recent queries are ordered and scoped per country', () => { }); }); +test('WHO recent queries do not let empty newer observations consume the result', async () => { + const runtime = loadRuntime(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + value: [ + { + IndicatorCode: 'WHOSIS_000001', SpatialDim: 'CHN', TimeDim: 2022, + NumericValue: null, Value: null, Dim1Type: 'SEX', Dim1: 'SEX_BTSX', + }, + { + IndicatorCode: 'WHOSIS_000001', SpatialDim: 'CHN', TimeDim: 2021, + NumericValue: 77.6, Value: '77.6', Dim1Type: 'SEX', Dim1: 'SEX_BTSX', + }, + ], + }), + })); + + const result = await runtime.call('who_health_data', { + indicator: 'life_expectancy', + countries: ['CHN'], + recent: 1, + }); + assert.equal(result.ok, true); + assert.equal(result.result.rows.length, 1); + assert.equal(result.result.rows[0].year, 2021); + assert.equal(result.result.rows[0].value, 77.6); +}); + test('settings page follows the host locale with an English fallback', () => { const html = fs.readFileSync(path.join(root, 'settings.html'), 'utf8'); const source = fs.readFileSync(path.join(root, 'settings.js'), 'utf8'); diff --git a/international-organization-data/main.js b/international-organization-data/main.js index 1b3074c..a0d7aa1 100644 --- a/international-organization-data/main.js +++ b/international-organization-data/main.js @@ -290,6 +290,7 @@ function whoFilter(countries, startYear, endYear, overallOnly) { if (typeof startYear === 'number') parts.push('TimeDim ge ' + Math.floor(startYear)); if (typeof endYear === 'number') parts.push('TimeDim le ' + Math.floor(endYear)); if (overallOnly) parts.push("Dim1 eq 'SEX_BTSX'"); + parts.push('(NumericValue ne null or Value ne null)'); return buildQuery(parts); } @@ -383,7 +384,9 @@ async function whoHealthData(args) { true ); if (!countryResult.ok) return countryResult; - var countryRows = normalizeWhoRows(countryResult.rows, indicator); + var countryRows = normalizeWhoRows(countryResult.rows, indicator).filter(function (row) { + return row.value !== null || row.displayValue !== null; + }); countryRows.sort(function (a, b) { return (b.year || 0) - (a.year || 0); }); From 2c9b83470fae159ccbe704eed464dbb688a246b7 Mon Sep 17 00:00:00 2001 From: xuxiaowei Date: Mon, 3 Aug 2026 14:01:46 +0800 Subject: [PATCH 4/4] fix(international-organization-data): address remaining review issues Signed-off-by: xuxiaowei --- .../international-organization-data.test.mjs | 52 ++++++++++++++++++- international-organization-data/ghost.json | 8 +-- .../locales/en.json | 2 +- .../locales/ja.json | 2 +- .../locales/ko.json | 2 +- .../locales/zh-CN.json | 2 +- international-organization-data/main.js | 46 +++++++++++++--- 7 files changed, 98 insertions(+), 16 deletions(-) diff --git a/.tests/international-organization-data.test.mjs b/.tests/international-organization-data.test.mjs index 7a5b87e..3bf6141 100644 --- a/.tests/international-organization-data.test.mjs +++ b/.tests/international-organization-data.test.mjs @@ -45,12 +45,13 @@ test('international organization data manifest is read-only and allowlisted', () assert.deepEqual(manifest.network.hosts, [ 'ghoapi.azureedge.net', 'faostatservices.fao.org', - 'www.fao.org', ]); assert.equal(manifest.network.secrets.length, 1); assert.equal(manifest.network.secrets[0].key, 'faostat_api_token'); assert.equal(manifest.network.secrets[0].inject.hosts[0], 'faostatservices.fao.org'); assert.equal(manifest.tools.length, 3); + assert.equal(manifest.version, '0.1.1'); + assert.equal(manifest.tools[1].parameters.properties.countries.maxItems, 25); assert.ok(manifest.tools.every((tool) => !/send|delete|write|update|create/i.test(tool.description))); }); @@ -150,6 +151,55 @@ test('WHO recent queries do not let empty newer observations consume the result' assert.equal(result.result.rows[0].value, 77.6); }); +test('WHO rejects country lists above the declared limit instead of truncating them', async () => { + let fetchCalls = 0; + const runtime = loadRuntime(async () => { + fetchCalls += 1; + throw new Error('fetch should not run'); + }); + const countries = Array.from({ length: 26 }, (_, index) => `C${String(index).padStart(2, '0')}`); + const result = await runtime.call('who_health_data', { + indicator: 'life_expectancy', + countries, + }); + assert.equal(result.ok, false); + assert.match(result.message, /最多支持 25 个国家/); + assert.match(result.message, /拆分为多次查询/); + assert.equal(fetchCalls, 0); +}); + +test('WHO year-range queries are ordered and disclose server-side truncation', async () => { + let requestedUrl = ''; + const runtime = loadRuntime(async ({ url }) => { + requestedUrl = decodeURIComponent(url); + return { + ok: true, + status: 200, + body: JSON.stringify({ + '@odata.count': 392, + value: [ + { IndicatorCode: 'WHOSIS_000001', SpatialDim: 'CHN', TimeDim: 2021, NumericValue: 77.6, Value: '77.6' }, + { IndicatorCode: 'WHOSIS_000001', SpatialDim: 'USA', TimeDim: 2021, NumericValue: 76.4, Value: '76.4' }, + ], + }), + }; + }); + const result = await runtime.call('who_health_data', { + indicator: 'life_expectancy', + startYear: 2020, + endYear: 2021, + limit: 2, + }); + assert.equal(result.ok, true); + assert.match(requestedUrl, /\$orderby=TimeDim desc/); + assert.match(requestedUrl, /\$count=true/); + assert.equal(result.result.responseTruncated, true); + assert.equal(result.result.totalMatched, 392); + assert.equal(result.result.recordsAvailableInPage, 2); + assert.equal(result.result.recordsReturned, 2); + assert.match(result.result.hint, /缩小年份范围/); +}); + test('settings page follows the host locale with an English fallback', () => { const html = fs.readFileSync(path.join(root, 'settings.html'), 'utf8'); const source = fs.readFileSync(path.join(root, 'settings.js'), 'utf8'); diff --git a/international-organization-data/ghost.json b/international-organization-data/ghost.json index f2424cf..208fe10 100644 --- a/international-organization-data/ghost.json +++ b/international-organization-data/ghost.json @@ -4,7 +4,7 @@ "name": "International Organization Data", "description": "查询 WHO 与 FAO 的官方健康、农业、粮食与食品安全统计数据。WHO 公共数据无需 API Key;FAOSTAT 数据需要在插件设置页配置官方 API Token。插件只读,不修改任何外部数据。", "whenToUse": "需要查询 WHO 全球健康指标,或 FAO 农业、粮食、食品安全、贸易与生产统计时使用。结果会保留数据源、指标、统计年份、原始显示值与更新时间;它不是实时监测、个人医疗诊断或投资建议工具。", - "version": "0.1.0", + "version": "0.1.1", "locales": { "en": "locales/en.json", "zh-CN": "locales/zh-CN.json", @@ -23,8 +23,7 @@ "network": { "hosts": [ "ghoapi.azureedge.net", - "faostatservices.fao.org", - "www.fao.org" + "faostatservices.fao.org" ], "secrets": [ { @@ -98,7 +97,7 @@ }, { "name": "who_health_data", - "description": "查询 WHO 全球健康指标。indicator 可传 WHO 指标代码或常用别名;countries 使用 ISO3 国家代码;可选年份范围。不传年份范围时必须提供 countries,插件会按国家分别查询并返回每个国家最近有效值,最终最多返回 countries 数量 × recent 条,避免全局截断遗漏国家。结果带国家、年份、数值、原始显示值、分组维度与 WHO 更新时间;不会把 WHO 的维度类型误标为计量单位。只读,无需 API Key。健康统计仅供信息参考,不用于个人诊断。", + "description": "查询 WHO 全球健康指标。indicator 可传 WHO 指标代码或常用别名;countries 使用 ISO3 国家代码,最多 25 个;可选年份范围。不传年份范围时必须提供 countries,插件会按国家分别查询并返回每个国家最近有效值,最终最多返回 countries 数量 × recent 条,避免全局截断遗漏国家。传年份范围时按年份从新到旧返回;若匹配记录超过 limit,会返回 responseTruncated、totalMatched、recordsAvailableInPage、recordsReturned 与缩小查询提示。结果带国家、年份、数值、原始显示值、分组维度与 WHO 更新时间;不会把 WHO 的维度类型误标为计量单位。只读,无需 API Key。健康统计仅供信息参考,不用于个人诊断。", "parameters": { "type": "object", "properties": { @@ -108,6 +107,7 @@ }, "countries": { "type": "array", + "maxItems": 25, "items": { "type": "string" }, diff --git a/international-organization-data/locales/en.json b/international-organization-data/locales/en.json index 7916aa5..4ff4d92 100644 --- a/international-organization-data/locales/en.json +++ b/international-organization-data/locales/en.json @@ -7,7 +7,7 @@ "description": "Browse official WHO or FAO data catalogs. WHO supports common indicators, health indicators, and countries; FAO supports groups, domains, dimensions, and filter codes. FAO catalog actions require an API token." }, "who_health_data": { - "description": "Query WHO global health indicators by WHO indicator code or common alias, ISO3 country codes, and optional year range. Without a year range, provide countries so the plugin can query each country separately and return up to countries × recent deterministic non-empty observations without global truncation. No API key is required; informational only, not personal medical diagnosis." + "description": "Query WHO global health indicators by WHO indicator code or common alias, up to 25 ISO3 country codes, and an optional year range. Without a year range, provide countries so the plugin can query each country separately and return up to countries × recent deterministic non-empty observations without global truncation. Year-range results are ordered newest first and explicitly disclose truncation beyond limit with guidance to narrow the query. No API key is required; informational only, not personal medical diagnosis." }, "fao_agriculture_data": { "description": "Query official FAOSTAT agriculture, food, food-security, trade, and production statistics. Discover domains and filter codes first; configure an official API token in plugin settings and provide at least one filter. Read-only." diff --git a/international-organization-data/locales/ja.json b/international-organization-data/locales/ja.json index fa74032..ca52b67 100644 --- a/international-organization-data/locales/ja.json +++ b/international-organization-data/locales/ja.json @@ -7,7 +7,7 @@ "description": "WHO または FAO の公式データカタログを参照します。WHO は一般的な指標、健康指標、国を、FAO はグループ、ドメイン、ディメンション、フィルターコードを扱います。FAO には API トークンが必要です。" }, "who_health_data": { - "description": "WHO の健康指標を指標コードまたは別名、ISO3 国コード、任意の年範囲で検索します。年範囲を指定しない場合は国コードが必要で、国ごとに別々に検索して最大で国数 × recent 件の最新の空でない観測値を返し、全体の切り捨てによる欠落を防ぎます。API キー不要で、情報提供のみです。" + "description": "WHO の健康指標を指標コードまたは別名、最大 25 件の ISO3 国コード、任意の年範囲で検索します。年範囲を指定しない場合は国コードが必要で、国ごとに別々に検索して最大で国数 × recent 件の最新の空でない観測値を返し、全体の切り捨てによる欠落を防ぎます。年範囲を指定した結果は新しい年順で返し、limit を超える切り捨てと検索を絞るための案内を明示します。API キー不要で、情報提供のみです。" }, "fao_agriculture_data": { "description": "FAOSTAT の農業、食料、食料安全保障、貿易、生産統計を検索します。先にドメインとフィルターコードを確認し、設定で公式 API トークンを指定してから、少なくとも一つのフィルターを渡します。読み取り専用です。" diff --git a/international-organization-data/locales/ko.json b/international-organization-data/locales/ko.json index 50e3483..6186d8a 100644 --- a/international-organization-data/locales/ko.json +++ b/international-organization-data/locales/ko.json @@ -7,7 +7,7 @@ "description": "WHO 또는 FAO 공식 데이터 카탈로그를 탐색합니다. WHO는 공통 지표, 보건 지표와 국가를 지원하고 FAO는 그룹, 도메인, 차원과 필터 코드를 지원합니다. FAO 카탈로그에는 API 토큰이 필요합니다." }, "who_health_data": { - "description": "WHO 글로벌 보건 지표를 WHO 코드 또는 별칭, ISO3 국가 코드와 선택적 연도 범위로 조회합니다. 연도 범위를 생략하면 국가 코드를 제공해야 하며 국가별로 따로 조회해 최대 국가 수 × recent개의 최신 비어 있지 않은 관측값을 반환하고 전체 잘림으로 국가가 누락되지 않도록 합니다. API 키가 필요 없으며 정보 제공용입니다." + "description": "WHO 글로벌 보건 지표를 WHO 코드 또는 별칭, 최대 25개의 ISO3 국가 코드와 선택적 연도 범위로 조회합니다. 연도 범위를 생략하면 국가 코드를 제공해야 하며 국가별로 따로 조회해 최대 국가 수 × recent개의 최신 비어 있지 않은 관측값을 반환하고 전체 잘림으로 국가가 누락되지 않도록 합니다. 연도 범위 결과는 최신 연도순으로 반환하며 limit을 넘는 잘림과 조회 범위를 줄이는 안내를 명시합니다. API 키가 필요 없으며 정보 제공용입니다." }, "fao_agriculture_data": { "description": "FAOSTAT 공식 농업, 식량, 식량안보, 무역 및 생산 통계를 조회합니다. 먼저 도메인과 필터 코드를 확인하고 설정에서 공식 API 토큰을 구성한 뒤 하나 이상의 필터를 전달해야 합니다. 읽기 전용입니다." diff --git a/international-organization-data/locales/zh-CN.json b/international-organization-data/locales/zh-CN.json index 7f27c3a..671849e 100644 --- a/international-organization-data/locales/zh-CN.json +++ b/international-organization-data/locales/zh-CN.json @@ -7,7 +7,7 @@ "description": "浏览 WHO 或 FAO 的官方数据目录。WHO 支持常用指标、健康指标和国家目录;FAO 支持数据组、数据域、维度和过滤代码。FAO 目录需要先配置 API Token。" }, "who_health_data": { - "description": "查询 WHO 全球健康指标。可使用 WHO 指标代码或常用别名,并按 ISO3 国家代码和年份筛选。不传年份时必须提供国家代码,插件会按国家分别查询最近有效值,最终最多返回国家数 × recent 条,避免全局截断遗漏国家。无需 API Key,仅供信息参考,不用于个人医疗诊断。" + "description": "查询 WHO 全球健康指标。可使用 WHO 指标代码或常用别名,并按最多 25 个 ISO3 国家代码和年份筛选。不传年份时必须提供国家代码,插件会按国家分别查询最近有效值,最终最多返回国家数 × recent 条,避免全局截断遗漏国家。传年份范围时按年份从新到旧返回,并明确披露超过 limit 的截断结果与缩小查询提示。无需 API Key,仅供信息参考,不用于个人医疗诊断。" }, "fao_agriculture_data": { "description": "查询 FAOSTAT 农业、粮食、食品安全、贸易等官方统计。先查数据域和过滤代码,再传入筛选条件;需要在插件设置页配置官方 API Token,且至少提供一个过滤条件。只读。" diff --git a/international-organization-data/main.js b/international-organization-data/main.js index a0d7aa1..7ff7026 100644 --- a/international-organization-data/main.js +++ b/international-organization-data/main.js @@ -118,8 +118,7 @@ function normalizeCountries(args) { return countries .filter(function (item) { return typeof item === 'string'; }) .map(function (item) { return item.trim().toUpperCase(); }) - .filter(Boolean) - .slice(0, MAX_COUNTRIES); + .filter(Boolean); } function resolveWhoIndicator(value) { @@ -299,9 +298,8 @@ function whoDataUrl(indicator, countries, startYear, endYear, overallOnly, top) + '?$top=' + top + '&$format=json'; var filter = whoFilter(countries, startYear, endYear, overallOnly); if (filter) url += '&$filter=' + encodeURIComponent(filter); - if (startYear === null && endYear === null) { - url += '&$orderby=' + encodeURIComponent('TimeDim desc'); - } + if (startYear !== null && endYear !== null) url += '&$count=true'; + url += '&$orderby=' + encodeURIComponent('TimeDim desc'); return url; } @@ -316,6 +314,9 @@ async function fetchWhoRows(indicator, countries, startYear, endYear, top, overa )); if (!result.ok) return result; var rows = envelopeRows(result.data); + var totalMatched = result.data && typeof result.data['@odata.count'] === 'number' + ? result.data['@odata.count'] + : null; if (!rows.length && overallOnly) { var fallback = await getJson(whoDataUrl( indicator, @@ -327,8 +328,11 @@ async function fetchWhoRows(indicator, countries, startYear, endYear, top, overa )); if (!fallback.ok) return fallback; rows = envelopeRows(fallback.data); + totalMatched = fallback.data && typeof fallback.data['@odata.count'] === 'number' + ? fallback.data['@odata.count'] + : null; } - return { ok: true, rows: rows }; + return { ok: true, rows: rows, totalMatched: totalMatched }; } function normalizeWhoRows(rows, indicator) { @@ -355,6 +359,12 @@ async function whoHealthData(args) { if (!indicator) return { ok: false, message: 'indicator 不能为空;可先调用 international_org_catalog(source=who, action=common)' }; var countries = normalizeCountries(args || {}); + if (countries.length > MAX_COUNTRIES) { + return { + ok: false, + message: 'countries 最多支持 ' + MAX_COUNTRIES + ' 个国家;请拆分为多次查询,每次不超过 ' + MAX_COUNTRIES + ' 个' + }; + } var startYear = typeof args.startYear === 'number' ? args.startYear : null; var endYear = typeof args.endYear === 'number' ? args.endYear : null; if ((startYear === null) !== (endYear === null)) { @@ -367,6 +377,7 @@ async function whoHealthData(args) { var limit = clampNumber(args.limit, 50, 1, MAX_LIMIT); var recent = clampNumber(args.recent, 1, 1, 20); var normalized = []; + var rangeMeta = null; if (startYear === null && !countries.length) { return { ok: false, @@ -396,8 +407,21 @@ async function whoHealthData(args) { var result = await fetchWhoRows(indicator, countries, startYear, endYear, limit, true); if (!result.ok) return result; normalized = normalizeWhoRows(result.rows, indicator); + var recordsAvailableInPage = result.rows.length; + var responseTruncated = result.totalMatched !== null + ? result.totalMatched > recordsAvailableInPage + : recordsAvailableInPage >= limit; + rangeMeta = { + responseTruncated: responseTruncated, + totalMatched: result.totalMatched, + recordsAvailableInPage: recordsAvailableInPage, + recordsReturned: normalized.length + }; + if (responseTruncated) { + rangeMeta.hint = '匹配记录超过本次返回上限;结果已按年份从新到旧截断。请缩小年份范围、减少国家数量,或提高 limit(最大 ' + MAX_LIMIT + ')后重试'; + } } - return { + var response = { ok: true, source: 'WHO', indicator: indicator, @@ -406,6 +430,14 @@ async function whoHealthData(args) { count: normalized.length, note: 'WHO 健康统计数据仅供信息参考,不用于个人医疗诊断。' }; + if (rangeMeta) { + response.responseTruncated = rangeMeta.responseTruncated; + response.totalMatched = rangeMeta.totalMatched; + response.recordsAvailableInPage = rangeMeta.recordsAvailableInPage; + response.recordsReturned = rangeMeta.recordsReturned; + if (rangeMeta.hint) response.hint = rangeMeta.hint; + } + return response; } async function faoAgricultureData(args) {