Add product-report proxy route for health food manufacturing data (I0030)

The existing health-food-ingredient route only covers individually-approved
ingredients (I-0040/I-0050), missing gazetted ingredients like psyllium husk
(차전자피). I0030 (건강기능식품 품목제조 신고사항) has 44k+ registered
products with raw materials, functionality, intake precautions, and standards.

The new /v1/mfds/food-safety/product-report route queries I0030 with
server-side PRDLST_NM and RAWMTRL_NM filters in parallel, deduplicates
by report number, and returns normalized results. Live-verified with
FOODSAFETYKOREA_API_KEY returning 차전자피 products with full precautions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-14 02:44:25 +09:00
commit 4c1989fdb2
5 changed files with 308 additions and 2 deletions

View file

@ -17,6 +17,7 @@ metadata:
조회 가능한 공식 데이터:
- **건강기능식품 기능성 원료 인정현황** (I-0040) — 원료가 공식 인정됐는지, 1일 섭취량, 섭취시 주의사항
- **건강기능식품 개별인정형 정보** (I-0050) — 개별 인정 원료의 기능성, 섭취량 상·하한, 주의사항
- **건강기능식품 품목제조 신고사항** (I0030) — 신고된 제품의 원재료, 기능성, 섭취 주의사항, 기준규격 (고시형 원료 포함)
- **검사부적합 현황(국내)** (I2620) — 국내 유통 식품 부적합 판정 이력
- **부적합 식품 목록** — 공공데이터포털 경유
- **회수·판매중지 공개 목록** (I0490) — 식품안전나라 경유
@ -76,8 +77,11 @@ red flag 가 있으면 식품 조회보다 **즉시 응급실·119·의료진
- 건강기능식품 개별인정형 sample: `https://openapi.foodsafetykorea.go.kr/api/sample/I-0050/json/1/5`
- 검사부적합(국내) (I2620): `https://www.foodsafetykorea.go.kr/api/openApiInfo.do?menu_grp=MENU_GRP31&menu_no=661&show_cnt=10&start_idx=1&svc_no=I2620&svc_type_cd=API_TYPE06`
- 검사부적합(국내) sample: `https://openapi.foodsafetykorea.go.kr/api/sample/I2620/json/1/5`
- 건강기능식품 품목제조 신고사항 (I0030): `https://www.foodsafetykorea.go.kr/api/openApiInfo.do?menu_grp=MENU_GRP31&menu_no=661&show_cnt=10&start_idx=1&svc_no=I0030&svc_type_cd=API_TYPE06`
- 건강기능식품 품목제조 신고 sample: `https://openapi.foodsafetykorea.go.kr/api/sample/I0030/json/1/5`
- 프록시 route: `GET /v1/mfds/food-safety/search`
- 프록시 route: `GET /v1/mfds/food-safety/health-food-ingredient`
- 프록시 route: `GET /v1/mfds/food-safety/product-report`
- 프록시 route: `GET /v1/mfds/food-safety/inspection-fail`
## Workflow
@ -85,7 +89,8 @@ red flag 가 있으면 식품 조회보다 **즉시 응급실·119·의료진
1. 증상/섭취상황이 있으면 인터뷰를 먼저 진행한다.
2. red flag 가 있으면 즉시 응급 안내로 전환한다.
3. "이거 먹어도 되나?" 류 질문:
- `/v1/mfds/food-safety/health-food-ingredient` 로 건강기능식품 원료 인정현황을 조회한다 (기능성, 1일 섭취량, 주의사항).
- `/v1/mfds/food-safety/product-report` 로 건강기능식품 품목제조 신고사항을 조회한다 (원재료, 기능성, 섭취 주의사항, 기준규격). 고시형 원료(차전자피 등)는 여기서 확인.
- `/v1/mfds/food-safety/health-food-ingredient` 로 건강기능식품 기능성 원료 인정현황을 조회한다 (개별인정형 원료의 1일 섭취량, 주의사항).
- `/v1/mfds/food-safety/inspection-fail` 로 국내 검사부적합 이력을 확인한다.
- `/v1/mfds/food-safety/search` 로 회수·부적합 공개 목록도 함께 조회한다.
4. 제품명, 업체명, 기능성, 섭취량, 주의사항, 부적합 사유를 짧게 정리하고, 먹어도 되는지 단정하지 않는다.
@ -104,7 +109,11 @@ python3 scripts/mfds_food_safety.py search --query "김밥" --limit 5
```
```bash
python3 scripts/mfds_food_safety.py health-food-ingredient --query "차전자피" --limit 5
python3 scripts/mfds_food_safety.py product-report --query "차전자피" --limit 5
```
```bash
python3 scripts/mfds_food_safety.py health-food-ingredient --query "스타놀" --limit 5
```
```bash

View file

@ -163,6 +163,23 @@ def search_health_food_ingredient(
return request_json(request)
def search_product_report(
query: str,
*,
limit: int = 10,
base_url: str | None = None,
request_json: Any = read_json_response,
) -> dict[str, Any]:
resolved_base_url = resolve_proxy_base_url(base_url)
url = f"{resolved_base_url}/v1/mfds/food-safety/product-report"
params = urllib.parse.urlencode({"query": query, "limit": str(limit)})
request = urllib.request.Request(
f"{url}?{params}",
headers={"Accept": "application/json", "User-Agent": "k-skill-mfds/1.0"},
)
return request_json(request)
def search_inspection_fail(
query: str,
*,
@ -193,6 +210,11 @@ def build_parser() -> argparse.ArgumentParser:
search.add_argument("--limit", type=int, default=10)
search.add_argument("--proxy-base-url")
product_report = subparsers.add_parser("product-report", help="search health food product manufacturing reports")
product_report.add_argument("--query", required=True)
product_report.add_argument("--limit", type=int, default=10)
product_report.add_argument("--proxy-base-url")
ingredient = subparsers.add_parser("health-food-ingredient", help="search health food ingredient recognition status")
ingredient.add_argument("--query", required=True)
ingredient.add_argument("--limit", type=int, default=10)
@ -221,6 +243,15 @@ def main(argv: list[str] | None = None) -> int:
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
if args.command == "product-report":
try:
payload = search_product_report(args.query, limit=args.limit, base_url=args.proxy_base_url)
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
except (ValueError, ApiError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
if args.command == "health-food-ingredient":
try:
payload = search_health_food_ingredient(args.query, limit=args.limit, base_url=args.proxy_base_url)

View file

@ -14,6 +14,9 @@ const HEALTH_FOOD_PRODUCT_LIVE_URL = "https://openapi.foodsafetykorea.go.kr/api/
const INSPECTION_FAIL_SAMPLE_URL = "https://openapi.foodsafetykorea.go.kr/api/sample/I2620/json/{start}/{end}";
const INSPECTION_FAIL_LIVE_URL = "https://openapi.foodsafetykorea.go.kr/api/{apiKey}/I2620/json/{start}/{end}";
const HEALTH_FOOD_PRODUCT_REPORT_SAMPLE_URL = "https://openapi.foodsafetykorea.go.kr/api/sample/I0030/json/{start}/{end}";
const HEALTH_FOOD_PRODUCT_REPORT_LIVE_URL = "https://openapi.foodsafetykorea.go.kr/api/{apiKey}/I0030/json/{start}/{end}";
class ProxyError extends Error {
constructor(message, { code = "proxy_error", statusCode = 502 } = {}) {
super(message);
@ -202,6 +205,22 @@ function normalizeHealthFoodProductRow(item) {
};
}
function normalizeProductReportRow(item) {
return {
source: "foodsafetykorea_product_report",
product_name: summarizeText(item.PRDLST_NM),
company_name: summarizeText(item.BSSH_NM),
raw_materials: summarizeText(item.RAWMTRL_NM),
individual_raw_materials: summarizeText(item.INDIV_RAWMTRL_NM),
functionality: summarizeText(item.PRIMARY_FNCLTY),
precautions: summarizeText(item.IFTKN_ATNT_MATR_CN),
standard: summarizeText(item.STDR_STND),
product_shape: summarizeText(item.PRDT_SHAP_CD_NM),
shelf_life: summarizeText(item.POG_DAYCNT),
approved_at: summarizeText(item.PRMS_DT)
};
}
function normalizeInspectionFailRow(item) {
return {
source: "foodsafetykorea_inspection_fail",
@ -484,10 +503,59 @@ async function fetchInspectionFail({ query, limit, foodsafetyKoreaApiKey, fetchI
};
}
async function fetchHealthFoodProductReport({ query, limit, foodsafetyKoreaApiKey, fetchImpl = global.fetch }) {
const warnings = [];
const fetchCount = Math.max(limit * 5, 50);
const baseUrl = foodsafetyKoreaApiKey
? HEALTH_FOOD_PRODUCT_REPORT_LIVE_URL.replace("{apiKey}", encodeURIComponent(foodsafetyKoreaApiKey))
: HEALTH_FOOD_PRODUCT_REPORT_SAMPLE_URL;
const items = [];
const searchPaths = [
`/PRDLST_NM=${encodeURIComponent(query)}`,
`/RAWMTRL_NM=${encodeURIComponent(query)}`
];
const fetches = searchPaths.map((path) =>
requestJson(
baseUrl.replace("{start}", "1").replace("{end}", String(fetchCount)) + path,
{ fetchImpl }
).catch((error) => { warnings.push(`I0030: ${error.message}`); return null; })
);
const results = await Promise.all(fetches);
const seen = new Set();
for (const payload of results) {
if (!payload) continue;
for (const row of extractFoodSafetyKoreaRows(payload, "I0030")) {
const key = row.PRDLST_REPORT_NO || `${row.PRDLST_NM}-${row.BSSH_NM}`;
if (!seen.has(key)) {
seen.add(key);
items.push(normalizeProductReportRow(row));
}
}
}
if (!foodsafetyKoreaApiKey) {
warnings.push("FOODSAFETYKOREA_API_KEY is not configured on the proxy server, so results use the public sample feed.");
}
return {
query,
items: items.slice(0, limit),
warnings: warnings.length > 0 ? warnings : undefined,
note: "이 결과는 공식 건강기능식품 품목제조 신고사항 기반 참고 정보이며, 섭취 가능 여부의 최종 판단은 의료진 상담이 우선입니다."
};
}
module.exports = {
fetchMfdsDrugLookup,
fetchMfdsFoodSafetySearch,
fetchHealthFoodIngredient,
fetchHealthFoodProductReport,
fetchInspectionFail,
normalizeMfdsDrugLookupQuery,
normalizeMfdsFoodSafetyQuery

View file

@ -8,6 +8,7 @@ const {
fetchMfdsDrugLookup,
fetchMfdsFoodSafetySearch,
fetchHealthFoodIngredient,
fetchHealthFoodProductReport,
fetchInspectionFail,
normalizeMfdsDrugLookupQuery,
normalizeMfdsFoodSafetyQuery
@ -1910,6 +1911,66 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
app.get("/v1/mfds/food-safety/product-report", async (request, reply) => {
let normalized;
try {
normalized = normalizeMfdsFoodSafetyQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "mfds-product-report",
...normalized,
hasKey: Boolean(config.foodsafetyKoreaApiKey)
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
let payload;
try {
payload = await fetchHealthFoodProductReport({
query: normalized.query,
limit: normalized.limit,
foodsafetyKoreaApiKey: config.foodsafetyKoreaApiKey
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message
};
}
payload.proxy = {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/korean-stock/search", async (request, reply) => {
let normalized;

View file

@ -2568,3 +2568,140 @@ test("mfds inspection-fail endpoint uses sample fallback without key and caches"
assert.equal(first.json().items[0].product_name, "쪽갓");
assert.match((first.json().warnings || []).join(" "), /sample feed/);
});
test("mfds product-report endpoint requires query", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/food-safety/product-report"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("mfds product-report endpoint uses sample fallback without key and caches", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("openapi.foodsafetykorea.go.kr/api/sample/I0030/json/1/")) {
return new Response(
JSON.stringify({
I0030: {
row: [
{
PRDLST_REPORT_NO: "20140017002183",
PRDLST_NM: "차전자피 다이어트",
BSSH_NM: "예시건강(주)",
RAWMTRL_NM: "차전자피식이섬유",
PRIMARY_FNCLTY: "배변활동 원활에 도움",
IFTKN_ATNT_MATR_CN: "충분한 물과 함께 섭취",
STDR_STND: "식이섬유: 표시량의 80% 이상",
PRDT_SHAP_CD_NM: "분말",
POG_DAYCNT: "제조일로부터 24개월",
PRMS_DT: "20200101"
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/mfds/food-safety/product-report?query=%EC%B0%A8%EC%A0%84%EC%9E%90%ED%94%BC&limit=5";
const first = await app.inject({ method: "GET", url });
const second = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(first.json().items[0].source, "foodsafetykorea_product_report");
assert.equal(first.json().items[0].product_name, "차전자피 다이어트");
assert.equal(first.json().items[0].raw_materials, "차전자피식이섬유");
assert.match((first.json().warnings || []).join(" "), /sample feed/);
});
test("mfds product-report endpoint uses live key with server-side filter", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("openapi.foodsafetykorea.go.kr/api/live-food-key/I0030/json/1/")) {
return new Response(
JSON.stringify({
I0030: {
row: [
{
PRDLST_REPORT_NO: "20200001",
PRDLST_NM: "차전자피 솔루션",
BSSH_NM: "예시바이오",
RAWMTRL_NM: "차전자피식이섬유",
PRIMARY_FNCLTY: "배변활동 원활",
IFTKN_ATNT_MATR_CN: "의사 상담 권장",
STDR_STND: "식이섬유 80% 이상",
PRDT_SHAP_CD_NM: "분말",
POG_DAYCNT: "24개월",
PRMS_DT: "20200527"
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
FOODSAFETYKOREA_API_KEY: "live-food-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/food-safety/product-report?query=%EC%B0%A8%EC%A0%84%EC%9E%90%ED%94%BC&limit=5"
});
assert.equal(response.statusCode, 200);
assert.ok(fetchCalls.some((entry) => entry.includes("live-food-key")));
assert.ok(fetchCalls.some((entry) => entry.includes("PRDLST_NM=")));
assert.ok(fetchCalls.some((entry) => entry.includes("RAWMTRL_NM=")));
assert.ok(!response.json().warnings || !response.json().warnings.some((w) => /sample feed/.test(w)));
});