mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
9dd5438f5f
commit
4c1989fdb2
5 changed files with 308 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue