feat: 다나와 판매 목록에서 하드웨어 스펙·국내 시세를 가져온다

공개 데이터셋 조사 결과 GPU 말고는 쓸 물건이 없었다. pc-part-dataset의
motherboard.json은 4,973건인데 필드가 7개뿐이고(칩셋·PCIe·M.2·VRM 없음,
색깔은 있음) 13개월 전이 마지막 갱신, CPU 쪽은 1~4년 낡았거나 인텔 전용
이거나 AGPL, 미니PC는 데이터셋 자체가 없다.

"광고에서 찾는 게 빠르겠다"는 사용자 지적이 맞았다. 파는 물건이라 낡을
수가 없고(낡으면 목록에서 사라진다), 데이터셋이 없는 미니PC도 상품 페이지는
반드시 있다. 같은 메인보드 질의 실측 비교:

  pc-part-dataset  name/price/socket/form_factor/max_memory/slots/colour
  다나와           AMD(소켓AM5) / AMD B650 / DDR5 / PCIe5.0 x16 /
                   M-ATX (24.4x24.4cm) / M.2 : 3개 / DrMOS / 전원부 방열판

라벨링은 GPU DB와 다르게 갔다. 저건 TechPowerUp 계측치라 "확정값"이지만
이건 판매처 표기이고 가격은 움직이는 값이라, 헤더에 그렇게 못박았다.
오늘 하루 주제가 근거에 정확한 라벨 붙이기였으니 출처를 부풀리지 않는다.

정중함은 선택이 아니라 제약으로 넣었다. robots.txt가 /dsearch.php는
허용하지만 Crawl-delay: 10이 걸려 있어서, 요청을 큐로 직렬화하고 10초
간격을 강제하며 30분 캐시를 둔다. 오늘 만든 비교 검색 쪼개기처럼 병렬로
3번 때리는 방식을 여기 쓰면 안 된다.

GPU에서 둘 다 걸릴 때 우선순위는 사용자 결정에 따라 다나와가 앞이고 스펙
DB는 폴백이다. 다만 버리지는 않고 출시일 한 줄은 남긴다 — 판매 목록엔 절대
없는 필드이고, 이 사태의 출발점인 "아직 출시 안 됨" 환각을 못 쓰게 만드는
게 정확히 그 필드다.

- parseDanawaHtml은 순수 함수로 분리해 저장된 페이지로 테스트한다. 스크래핑
  마크업은 언젠가 깨지는데 진짜 위험은 "조용히" 깨지는 것이라, 0건 파싱은
  전부 데이터 없음으로 처리하고 회귀는 테스트가 잡는다
- 가격은 price_sect 안의 <strong>만 인정한다. 블록 첫 <strong>은 평점일 수
  있어 실제 페이지에서 "65"·"2"가 가격으로 잡히는 걸 확인하고 좁혔다
- isPcPartQuery는 부품 신호 + 스펙/가격 의도가 함께 있을 때만 참이다.
  10초 딜레이가 있으니 조회를 아껴 쓴다. "메인보드에 CPU 끼우는 법"은 안 잡음
- 테스트가 구멍을 하나 잡았다: 카테고리 명사만 넣었더니 "RTX 5060 최저가"가
  통과 못 했다. 사람은 부품을 모델명으로 부른다 — 모델명 표기를 추가

실측: "B650 메인보드 스펙" 1.7초, "미니PC 가격" 10.1초(크롤 딜레이 작동 확인)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-11 14:50:18 +09:00
co-authored by Claude Opus 5
parent 231ecbfce6
commit 2e6530db6b
3 changed files with 288 additions and 9 deletions
+164
View File
@@ -0,0 +1,164 @@
/**
* danawa.ts
*
* Hardware specs and current Korean prices, read from product listings.
*
* WHY LISTINGS. Verified 2026-08-11 while looking for motherboard/CPU/mini-PC equivalents of the
* GPU database: the open datasets are not usable. pc-part-dataset's motherboard.json has 4,973
* entries but only 7 fields — no chipset, no PCIe, no M.2 count, no VRM (it does have the colour) —
* and was last updated 13 months ago. The CPU options are 1–4 years stale, Intel-only, or AGPL.
* For mini PCs no dataset exists at all. Listings have none of those problems for a simple reason:
* they describe things currently for sale, so they cannot go stale without going empty, and a
* mini PC that has no dataset entry certainly has a product page.
*
* The measured contrast, same query, motherboard:
* pc-part-dataset name / price / socket / form_factor / max_memory / memory_slots / colour
* danawa AMD(소켓AM5) / AMD B650 / DDR5 / PCIe5.0 x16 / M-ATX (24.4x24.4cm) /
* M.2 : 3개 / USB A타입 : 7개 / 2.5Gbps / DrMOS / 전원부 방열판 / UEFI
*
* WHAT THIS IS NOT. These figures come from sellers, not from a lab. Danawa normalises them into
* a consistent spec string, which is far better than raw ad copy, but it is still merchant-stated
* and the price is a snapshot of a moving number. So the injected block says so. Today's whole
* theme was labelling grounding accurately — a source is not improved by overstating it, and this
* one must not be dressed up as the measured GPU database it sits next to
* ([[project_gpu_specs_db]]).
*
* POLITENESS IS A HARD CONSTRAINT, NOT A NICETY. robots.txt permits /dsearch.php (it disallows
* /api_ui/, /classes/, /genfile/ and friends) but sets `Crawl-delay: 10`. So requests are
* serialised behind a 10-second gate and answered from a short-lived cache wherever possible.
* This is why the comparison-split path must never fan this out the way it fans out web_search.
*/
const SEARCH_URL = 'https://search.danawa.com/dsearch.php?query=';
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
const FETCH_TIMEOUT_MS = 15_000;
/** robots.txt: Crawl-delay: 10. Not negotiable — see the header. */
const CRAWL_DELAY_MS = 10_000;
const CACHE_TTL_MS = 30 * 60 * 1000;
const MAX_ITEMS = 5;
export interface DanawaItem { name: string; spec: string; price: number | null }
const cache = new Map<string, { at: number; items: DanawaItem[] }>();
let lastFetchAt = 0;
let queue: Promise<unknown> = Promise.resolve();
const stripTags = (s: string) => s.replace(/<[^>]+>/g, ' ');
const decode = (s: string) => s
.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
.replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
const clean = (s: string) => decode(stripTags(s)).replace(/\s+/g, ' ').trim();
/**
* Pulls products out of a results page.
*
* Exported and pure so the parser can be tested against a saved page: this is scraped markup, so
* it will break when Danawa changes its templates, and the failure mode that matters is breaking
* SILENTLY. A zero-item parse is therefore treated as "no data" by every caller rather than as an
* error worth surfacing to the user, and the test suite is what notices the regression.
*/
export function parseDanawaHtml(htmlText: string): DanawaItem[] {
const html = String(htmlText || '');
const blocks = html.match(/<li[^>]*class="prod_item[^"]*"[^>]*>[\s\S]*?<\/li>/g) || [];
const items: DanawaItem[] = [];
for (const b of blocks) {
const nameM = b.match(/<p class="prod_name">\s*<a[^>]*>([\s\S]*?)<\/a>/);
if (!nameM) continue;
const name = clean(nameM[1]);
if (!name) continue;
const specM = b.match(/<div class="spec_list">([\s\S]*?)<\/div>/);
const spec = specM ? clean(specM[1]) : '';
// The first <strong> in a product block is not the price (it can be a rating or a review
// count); the one inside price_sect is. Verified against a live page: 7/8 vs junk like "65".
const priceM = b.match(/class="price_sect[^"]*"[\s\S]*?<strong>([\d,]+)<\/strong>/);
const price = priceM ? Number(priceM[1].replace(/,/g, '')) : null;
items.push({ name, spec, price: price && price >= 1000 ? price : null });
}
return items;
}
/** Serialises every request behind the crawl delay, so concurrent chats cannot burst. */
function scheduleFetch<T>(fn: () => Promise<T>): Promise<T> {
const run = queue.then(async () => {
const wait = CRAWL_DELAY_MS - (Date.now() - lastFetchAt);
if (wait > 0) await new Promise(r => setTimeout(r, wait));
lastFetchAt = Date.now();
return fn();
});
queue = run.catch(() => undefined);
return run;
}
export async function searchDanawa(query: string): Promise<DanawaItem[]> {
const q = String(query || '').trim();
if (!q) return [];
const hit = cache.get(q);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.items;
try {
const items = await scheduleFetch(async () => {
const res = await fetch(SEARCH_URL + encodeURIComponent(q), {
headers: { 'User-Agent': UA, 'Accept-Language': 'ko-KR,ko;q=0.9' },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) return [];
return parseDanawaHtml(await res.text());
});
cache.set(q, { at: Date.now(), items });
return items;
} catch {
return [];
}
}
// ── Query gating ──────────────────────────────────────────────────────────────
/**
* PC-part nouns, in the words people actually use (including the loanword spellings Danawa itself
* uses). Kept to hardware categories: this exists to answer "what are the specs / what does it
* cost", not to turn every shopping-shaped question into a scrape.
*/
// 2026-08-11, 테스트가 잡은 구멍: 카테고리 명사만 넣었더니 "RTX 5060 최저가"가 통과 못 했다.
// 사람들은 부품을 카테고리가 아니라 모델명으로 부른다 — 모델명 표기를 부품 신호로 함께 잡는다.
const PC_PART_KEYWORD = /메인\s*보드|마더\s*보드|메인보드|미니\s*pc|미니pc|베어본|씨피유|시피유|\bcpu\b|프로세서|라이젠|ryzen|인텔\s*코어|core\s*(ultra|i[3579])\b|\b(rtx|gtx|rx|arc)\s*\d{3,4}\b|\bi[3579][-\s]?\d{4,5}\b|그래픽\s*카드|그래픽카드|\bgpu\b|지포스|geforce|라데온|radeon|램\s*(가격|스펙)|메모리\s*(가격|스펙)|ddr[45]\b|파워\s*서플라이|파워서플라이|\bssd\b|엔브이엠이|nvme|쿨러|수랭|공랭|케이스\s*(가격|추천)|칩셋|소켓\s*am[45]|lga\s*17\d\d/i;
/** The question has to be about specs or price for a listing to be the right answer. */
const SPEC_OR_PRICE_INTENT = /스펙|사양|성능|비교|차이|가격|얼마|시세|최저가|추천|어떤\s*(게|것|제품|모델)|spec|price/i;
export function isPcPartQuery(text: string): boolean {
const s = String(text || '');
return PC_PART_KEYWORD.test(s) && SPEC_OR_PRICE_INTENT.test(s);
}
// ── Formatting ────────────────────────────────────────────────────────────────
const won = (n: number) => `${n.toLocaleString('ko-KR')}원`;
export function formatDanawaItems(items: readonly DanawaItem[]): string {
return items.slice(0, MAX_ITEMS).map(it => {
const head = it.price ? `${it.name} — ${won(it.price)}` : it.name;
return it.spec ? `${head}\n ${it.spec}` : head;
}).join('\n');
}
/**
* The block appended to a search result, or null when nothing usable came back.
*
* The wording of the header does real work: it tells the model these are seller-stated figures at
* a moment in time, so it does not present a listing spec with the same confidence as a measured
* one, and does not quote a price as if it were fixed.
*/
export async function lookupDanawa(query: string): Promise<string | null> {
if (!isPcPartQuery(query)) return null;
const items = await searchDanawa(query);
const usable = items.filter(i => i.spec || i.price);
if (!usable.length) return null;
return `[다나와 판매 목록 — 판매처가 기재한 스펙과 ${new Date().toLocaleDateString('ko-KR')} 기준 국내 시세]\n`
+ `계측치가 아니라 판매 정보입니다. 스펙은 판매처 표기이고 가격은 변동하니, 그렇게 밝혀서 답하세요.\n\n`
+ formatDanawaItems(usable);
}
+31 -9
View File
@@ -1,6 +1,7 @@
import { ToolResult } from '../types.js';
import { getConfig } from '../config/config.js';
import { lookupGpuSpecs } from './gpu-specs.js';
import { lookupDanawa } from './danawa.js';
type SearchResultItem = { title: string; url: string; snippet: string };
@@ -912,11 +913,12 @@ export async function executeWebSearch(args: { query: string; max_results?: numb
const split = splitComparisonQuery(args.query || '');
const base = split ? await runComparisonSearch(args, split) : await runSearchProviders(args);
return withGpuSpecs(args.query, base);
return withHardwareContext(args.query, base);
}
/**
* Prepends structured GPU specifications when the query named a card we have data for.
* Prepends hardware grounding — current listings and/or measured GPU specs — when the query is
* about a PC part.
*
* Injected rather than exposed as a tool the model must decide to call: across a 3,300-line
* production log (2026-08-11) the model called web_fetch exactly once and never reached for it on
@@ -924,19 +926,39 @@ export async function executeWebSearch(args: { query: string; max_results?: numb
* the fourth time today the same conclusion was reached about the same model
* ([[feedback_local_model_needs_code_backstop]]).
*/
async function withGpuSpecs(query: string, res: ToolResult): Promise<ToolResult> {
async function withHardwareContext(query: string, res: ToolResult): Promise<ToolResult> {
if (!res.success) return res;
try {
const specs = await lookupGpuSpecs(query || '');
if (!specs) return res;
console.log('[v2] web_search: GPU 스펙 DB 주입');
const [danawa, gpu] = await Promise.all([
lookupDanawa(query || ''),
lookupGpuSpecs(query || ''),
]);
if (!danawa && !gpu) return res;
// Precedence when both fire on the same part (user's call, 2026-08-11): the listing leads,
// because for a part you can actually buy the question is usually "what is sold and what does
// it cost", and the spec database is held back to a fallback.
//
// Held back, not dropped: the database's releaseDate is the one field a listing never carries,
// and it is what makes "이 카드는 아직 출시되지 않았다" unwritable — the failure that started
// this whole thread. So when the listing leads, the database contributes its release line only.
const blocks: string[] = [];
if (danawa && gpu) {
blocks.push(danawa);
const releaseLine = gpu.split('\n').find(l => /출시 \d{4}-\d{2}-\d{2}/.test(l));
if (releaseLine) blocks.push(`[스펙 DB 출시일 확인] ${releaseLine.trim()}`);
} else {
blocks.push((danawa || gpu) as string);
}
console.log(`[v2] web_search: 하드웨어 컨텍스트 주입 (${danawa ? '다나와' : ''}${danawa && gpu ? '+' : ''}${gpu ? 'GPU DB' : ''})`);
return {
...res,
data: { ...(res.data as any || {}), gpu_specs_injected: true },
stdout: `${specs}\n\n${String(res.stdout || '').trim()}`,
data: { ...(res.data as any || {}), danawa_injected: !!danawa, gpu_specs_injected: !!gpu },
stdout: `${blocks.join('\n\n')}\n\n${String(res.stdout || '').trim()}`,
};
} catch {
// Spec injection is an add-on; any failure must leave the search result untouched.
// Context injection is an add-on; any failure must leave the search result untouched.
return res;
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* danawa — 상품 목록 파싱과 조회 게이트
*
* 스크래핑한 마크업을 파싱하므로 다나와가 템플릿을 바꾸면 깨진다. 진짜 위험은 깨지는 것 자체가
* 아니라 "조용히" 깨지는 것이다 — 파싱 0건은 호출부가 전부 "데이터 없음"으로 처리하므로 사용자
* 눈엔 아무 일도 안 일어난 것처럼 보인다. 그걸 알아채는 게 이 테스트의 역할이다.
*
* HTML 조각은 2026-08-11 실제 페이지에서 딴 것이다.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { parseDanawaHtml, isPcPartQuery, formatDanawaItems } from '../src/tools/danawa';
const PAGE = `
<ul>
<li class="prod_item prod_layer">
<p class="prod_name"><a href="http://prod.danawa.com/info/?pcode=1">MSI MAG B650M 박격포 WIFI</a></p>
<div class="spec_list">AMD(소켓AM5) / AMD B650 / DDR5 / VGA 연결 : PCIe5.0 x16 / M-ATX (24.4x24.4cm) / M.2 : 3개 / DrMOS / UEFI</div>
<p class="price_sect"><a href="#"><strong>214,000</strong></a></p>
</li>
<li class="prod_item">
<p class="prod_name"><a href="#">GEEKOM IT13 i9-13900HK Win11Pro</a></p>
<div class="spec_list">i9-13900HK / Iris Xe &amp; 그래픽스 / DDR4 / 16GB / M.2 / 1TB</div>
<p class="price_sect"><a href="#"><strong>1,151,000</strong></a></p>
</li>
<li class="prod_item">
<p class="prod_name"><a href="#">가격 없는 상품</a></p>
<div class="spec_list">AMD(소켓AM5)</div>
<span class="rating"><strong>65</strong></span>
</li>
</ul>`;
describe('parseDanawaHtml — 상품 블록 파싱', () => {
const items = parseDanawaHtml(PAGE);
test('상품을 모두 잡는다', () => {
assert.equal(items.length, 3);
assert.equal(items[0].name, 'MSI MAG B650M 박격포 WIFI');
});
test('스펙 문자열을 정규화해서 가져온다 — 이게 공개 데이터셋에 없던 알맹이다', () => {
assert.match(items[0].spec, /AMD B650/);
assert.match(items[0].spec, /PCIe5\.0 x16/);
assert.match(items[0].spec, /M\.2 : 3개/);
assert.match(items[0].spec, /DrMOS/);
});
test('HTML 엔티티를 푼다', () => {
assert.match(items[1].spec, /Iris Xe & 그래픽스/);
});
test('가격은 price_sect 안의 것만 인정한다', () => {
// 상품 블록의 첫 <strong>은 평점/리뷰수일 수 있다. 실제 페이지에서 "65", "2" 같은 값이
// 가격으로 잡히는 걸 확인하고 선택자를 좁혔다.
assert.equal(items[0].price, 214000);
assert.equal(items[1].price, 1151000);
assert.equal(items[2].price, null);
});
test('마크업이 바뀌면 0건 — 호출부가 "데이터 없음"으로 처리할 신호', () => {
assert.deepEqual(parseDanawaHtml('<div>완전히 다른 구조</div>'), []);
assert.deepEqual(parseDanawaHtml(''), []);
});
});
describe('isPcPartQuery — 언제 조회할 것인가', () => {
test('부품 + 스펙/가격 의도가 함께 있어야 조회한다', () => {
assert.equal(isPcPartQuery('B650 메인보드 추천'), true);
assert.equal(isPcPartQuery('미니PC 가격 얼마야'), true);
assert.equal(isPcPartQuery('라이젠 9800X3D 스펙'), true);
assert.equal(isPcPartQuery('RTX 5060 최저가'), true);
});
test('부품 얘기라도 스펙/가격을 묻지 않으면 조회하지 않는다', () => {
// 크롤 딜레이가 10초라 아무 때나 긁으면 안 된다. 조회는 아껴 쓴다.
assert.equal(isPcPartQuery('메인보드에 CPU 끼우는 법 알려줘'), false);
assert.equal(isPcPartQuery('그래픽카드 드라이버 오류 고쳐줘'), false);
});
test('하드웨어와 무관한 질문은 건드리지 않는다', () => {
assert.equal(isPcPartQuery('오늘 서울 날씨'), false);
assert.equal(isPcPartQuery('아파트 가격 얼마야'), false);
});
});
describe('formatDanawaItems — 출력', () => {
test('가격이 있으면 원화로, 없으면 이름만', () => {
const out = formatDanawaItems(parseDanawaHtml(PAGE));
assert.match(out, /MSI MAG B650M 박격포 WIFI — 214,000원/);
assert.match(out, /^가격 없는 상품$/m);
});
});