Files
homeclaw/tests/danawa.test.ts
T
kimandClaude Opus 5 2e6530db6b 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>
2026-08-11 14:50:18 +09:00

94 lines
4.1 KiB
TypeScript

/**
* 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);
});
});