import { readFile } from 'node:fs/promises'; import pg from 'pg'; import { storedGroup, tfdaGroup } from '../groups.ts'; import { parseCsv } from './cnf.ts'; import { importDrafts, rowObject, toNumber } from './national.ts'; import type { Cell, Draft, NutrientKey, Parsed } from './national.ts'; import { openZip } from './xlsx.ts'; // Taiwan Food Composition Database ("Nutrition Information Database", Food and Drug Administration, TFDA), // SRC-20. Open data under the Open Government Data License, version 1.0 (data.gov.tw/en/datasets/8543), // which grants a "perpetual, worldwide, non-exclusive, irrevocable, royalty-free copyright license ... for any // purpose, including ... Derivative Works either as products or services" and requires the attribution of // its Exhibit. The CSV (zipped, one long table: one row per food and analysis item) is the official export. export const tfdaSourceName = 'Nutrition Information Database, Food and Drug Administration, Taiwan (TFDA)'; export const tfdaAttribution = 'Food and Drug Administration, Ministry of Health and Welfare, Taiwan, 2026, Nutrition Information Database. ' + 'The Open Data is made available to the public under the Open Government Data License, version 1.0: https://data.gov.tw/license'; const columns = ['食品分類', '資料類別', '整合編號', '樣品名稱', '俗名', '樣品英文名稱', '內容物描述', '廢棄率', '分析項分類', '分析項', '含量單位', '每100克含量', '樣本數', '標準差', '每單位含量', '每單位重', '每單位重含量']; // Analysis item + unit -> key. "總碳水化合物" is carbohydrate by difference, so it includes fibre (FD-11 `total`). const items: Record = { '熱量': ['enerc_kcal', 'kcal'], '粗蛋白': ['procnt_g', 'g'], '粗脂肪': ['fat_g', 'g'], '總碳水化合物': ['chocdf_g', 'g'], '膳食纖維': ['fibtg_g', 'g'], '糖質總量': ['sugar_g', 'g'], '飽和脂肪': ['fasat_g', 'g'], '鈉': ['na_mg', 'mg'], '膽固醇': ['chole_mg', 'mg'], '鉀': ['k_mg', 'mg'], '鈣': ['ca_mg', 'mg'], '鐵': ['fe_mg', 'mg'], '維生素D總量(ug)': ['vitd_ug', 'ug'], '酒精含量': ['alc_g', 'g'], '水分': ['water_g', 'g'], }; // The table gives a sample count (樣本數) for every value it analysed and none for the values it computes from // them; of those only these three are read, and they are `calculated`. Any other value without a sample count // stays unknown (FD-07). const computedItems = new Set(['熱量', '總碳水化合物', '飽和脂肪', '糖質總量']); const clean = (cell: Cell | undefined) => (cell ?? '').replace(/\s+/g, ' ').trim(); export function parseTfda(zipBytes: Buffer): Parsed { const archive = openZip(zipBytes); const file = archive.names.find(name => name.toLowerCase().endsWith('.csv')); const bytes = file ? archive.read(file) : null; if (!bytes) throw new Error('TFDA zip has no CSV'); const [header, ...rows] = parseCsv(bytes); if (!header || columns.some((title, at) => header[at]?.trim() !== title)) throw new Error('TFDA columns changed'); const byFood = new Map(); for (const row of rows) { const id = clean(row[2]); if (id) byFood.set(id, [...(byFood.get(id) ?? []), row]); } const out: Parsed = { drafts: [], skipped: [] }; for (const [id, list] of byFood) { const head = list[0]; // The English field lists synonyms joined by ; or 、; the first is the name, the rest are kept. const english = clean(head[5]).split(/\s*[;;、]\s*/).filter(Boolean); const native = clean(head[3]); const name = english[0] ?? native; if (!name) { out.skipped.push({ recordId: id, reason: 'no name' }); continue; } const values: Draft['values'] = {}; const kinds: Draft['kinds'] = {}; const seen = new Set(); let broken = false; for (const row of list) { const known = items[clean(row[9])]; if (!known) continue; if (clean(row[10]) !== known[1]) throw new Error(`TFDA unit of ${clean(row[9])} changed`); if (seen.has(known[0])) { broken = true; break; } seen.add(known[0]); const value = toNumber(clean(row[11])); const count = toNumber(clean(row[12])); const type = count !== null && count >= 1 ? 'analytic' : computedItems.has(clean(row[9])) ? 'calculated' : null; if (value === null || value < 0 || !type) continue; values[known[0]] = value; kinds[known[0]] = type; } if (broken) { out.skipped.push({ recordId: id, reason: 'duplicate analysis item' }); continue; } if (!Object.keys(values).length) { out.skipped.push({ recordId: id, reason: 'no composition rows' }); continue; } out.drafts.push({ recordId: id, name, basis: '100g', carbohydrateDefinition: 'total', values, kinds, measures: [], group: storedGroup(tfdaGroup(clean(head[0]))), added: { name_language: english.length ? 'en' : 'zh', native_name: native || null, native_language: 'zh', synonyms_en: english.slice(1), tfda_category: clean(head[0]), tfda_record_class: clean(head[1]) }, raw: Buffer.from(JSON.stringify(list.map(row => rowObject(header, row)))), }); } return out; } export async function importTfdaFile(client: pg.Client, path: string, releasedAt: string) { return importDrafts(client, { src: 'SRC-20', market: 'TW', family: 'open' }, parseTfda(await readFile(path)), releasedAt, tfdaSourceName); }