앞서 진행한 프로젝트에서는 "한식" 을 카테고리로 갖는 음식점 정보를 갖고 오는 것을 테스트 하였습니다. 이번에는 네이버에서 제공하는 필터값을 사용하여 해당 필터에 해당되는 음식점 id 값들을 갖고 오도록 합니다
음식점 id ?
: id값의 경우 이후 사용하게 될 메뉴정보와 기타 매장 정보를 스크래핑하기 위해 필요되는 필수 파라미터 값입니다. 이전에는 그저 음식점 위치와 이름등을 저장하였다면 id값을 통해 한번더 스크래핑하여 메뉴정보와 가격정보를 갖고 오도록 합니다
먼저 음식점 선택의 기준이 될 수 있는 것에는 평점, 음식의 종류, 음식의 가격이 필수적이라고 생각되었습니다. 이에따라 세부 메뉴정보와 대표 메뉴를 선정하여 사용자가 가격 limit을 설정하게 되었을 때에 해당되는 정보만 DB에서 빼올 수 있도록 이러한 정보들을 스크래핑하게 됩니다
@Getter
@RequiredArgsConstructor
public enum RestaurantType {
KOREAN("한식"),
WESTERN("양식"),
ASIAN("아시아음식"),
JAPAN("일식"),
CHINESE("중식"),
SNACK("분식"),
CAFE("카페"),
BUFFET("뷔페"),
OTHERS("기타")
;
private final String label;
public static List<String> getLabels(){
List<String> labels = new ArrayList<>();
Arrays.stream(RestaurantType.values()).forEach(type -> labels.add(type.getLabel()));
return labels;
}
}
로깅 차원에서 보기 편하도록 getLables()를 만들어두었습니다. 쓰이게 될 지는 모르겠지만 차후 개발 과정에서 제외될 메서드입니다
네이버에서 제공하는 필터들을 탐색하여 해당 필터값에 해당되는 value 와 라벨을 지정하게 되었습니다.
필터를 지정하였을 때 패킷의 모습
즉, food 에 파라미터값이 입력 되는 것을 볼 수 있습니다.
양식, 한식으로 필터를 지정하였을 때 한식과 양식 둘중 하나라도 해당되는 음식점 정보가 매핑되는 것을 확인하였습니다
서비스를 만들게 될 때에 어차피 해당 부분은 따로 쿼리를 통하여 설정할것이기 때문에 네이버의 카테고리별로 음식들을 저장하도록 합니다
@Test
@DisplayName("")
public void getRestaurantData_v2 () throws Exception {
for (RestaurantType type : RestaurantType.values()) {
// 카테고리내의 모든 음식들을 크롤링
String url = "/graphql";
String _url = HOST_v2+url;
GetRestaurantRequest request = GetRestaurantRequest.builder()
.x(x)
.y(y)
.bounds("mySurroundedArea")
.query("음식점")
.type(type)
.build();
String jsonOperation = naverUtility.getRestaurants(request);
HttpHeaders httpHeaders = utility.getDefaultHeader();
HttpEntity requestMessage = new HttpEntity(jsonOperation,httpHeaders);
ResponseEntity response = restTemplate.exchange(
_url,
HttpMethod.POST,
requestMessage,
String.class);
List<Restaurant> entities = new ArrayList<>();
JSONArray datas = new JSONArray(response.getBody().toString());
datas.getJSONObject(0);
JSONArray items = datas.getJSONObject(0).getJSONObject("data").getJSONObject("restaurants").getJSONArray("items");
int total = Integer.parseInt(datas.getJSONObject(0).getJSONObject("data").getJSONObject("restaurants").getString("total"));
int maxCnt = total<100? total:100;
for (int i = 0; i < maxCnt; i++) {
GetRestaurantResponse mapped_data = gson.fromJson(items.getString(i),GetRestaurantResponse.class);
//1. first map with entity : 엔티티와 매핑하기전 validation을 거친다
Restaurant restaurant = Restaurant.builder()
.id(Long.parseLong(mapped_data.getId()))
.address(mapped_data.getAddress())
.category(mapped_data.getCategory()==null?"없음": mapped_data.getCategory())
.imageUrl(mapped_data.getImageUrl()==null?"":URLDecoder.decode(mapped_data.getImageUrl(),"UTF-8"))
.name(mapped_data.getName())
.distance(utility.stringToLongDistance(mapped_data.getDistance()))
.businessHours(mapped_data.getBusinessHours())
.visitorReviewScore(mapped_data.getVisitorReviewScore()==null? 0.0 : Double.parseDouble(mapped_data.getVisitorReviewScore()))
.saveCount(utility.stringToLongSaveCnt(mapped_data.getSaveCount()))
.bookingReviewScore(mapped_data.getBookingReviewScore())
.restaurantType(type)
.build();
entities.add(restaurant);
}
restaurantsRepository.saveAll(entities);
}
List<Long> ids = restaurantsRepository.findAllreturnId();
for (Long id : ids) {
System.out.println(id);
}
}
프로세스 자체는 동일합니다. 다만 values 를 이용하여 하나씩 카테고리를 set 하여 저장하도록 합니다 for (RestaurantType type : RestaurantType.values())
시험용으로 100개의 카테고리를 받도록 하였는데 토탈값이 100를 초과하는 경우 에러가 발생하여 total을 따로 받아주어 100초과시 100 아니라면 total 값으로 저장하도록 하였습니다 int maxCnt = total<100? total:100;
그리고 validation 과정에서 해당 부분이 null 인경우에 바꾸어 저장하도록 수정하였습니다
추가적으로 restaurantsRepository.findAllreturnId() 를 통하여 id 값들을 뽑아 올 수 있는지 확인하였습니다
일전에 했던 프로젝트에서도 다소의 삽질으로 html 을 파싱하여 레스토랑 정보를 갖고 오는 것을 했었는데 v1이후 v2를 통해 graphql 을 통한 json 데이터를 바로 받아올 수 있는 것을 확인한 이후 레스토랑 세부정보또한 이것이 가능한지 확인했습니다
피들러를 통해 분석하여 세부정보를 볼 수 있는 url 을 확인합니다
https://pcmap.place.naver.com/restaurant/{restaurantID}/home
해당 페이지로 접근시 html 로 페이지에 뿌려지는 것이 확인됩니다
메뉴 클릭시
리뷰 클릭시
menu 클릭시 graphql 이 호출되지 않는 것으로 보아 query를 통하여 json 데이터를 받는 것이 어렵다는 것을 확인 하였습니다
이에 따라 html 파싱으로 결정하게 되었습니다. (제발 DOM 이 중간에 바뀌지 않길 빌면서...)
우선 방대한 50만자 이상의 html 중 데이터를 추출하기란 쉽지 않습니다
이중 Apollo 의 서버사이드 렌더링을 통하여 데이터를 raw 하게 뿌려주는 것을 확인 하였고 이 API 를 직접 호출하면 어떨까? 하는 생각이 들었지만 크리덴셜 이슈와 지식의 부족으로 아쉽게 html 파싱으로 진행합니다
가장 중요한것은 뽑아올수 있는 데이터가 무엇이 있느냐 입니다
위의 샘플을 통하여 추출할 만한 필드를 생각하였을 때
이름, 가격, recommend, image 로 결정하였습니다
그리고 추출해야 하는 Json Object의 패턴으로 보아 Menu:num_index 의 구조를 띔을 알 수 있는데 이를 통하여 메뉴에 해당하는 정보만 뽑아 올 수 있도록 합니다
이미지나 recommend 의 경우 없을수도 있기때문에 DB에 담기전 따로 파싱하여 거칠 예정입니다
이를 토대로 메뉴 세부 모델을 작성합니다
@Getter
@Setter
@ToString
public class RestaurantDetailResponse {
private String name;
private String price;
private JsonObject images;
private String imgUrl;
}
다만 이미지의 경우 json 으로 한번더 감싸져있기 때문에 JsonObject 로 매핑한 후 따로 넣도록 합니다
@Test
@DisplayName("")
public void givenIdListSearchAndSaveRestaurantDetail () throws Exception {
// given
List<Long> ids = Arrays.asList(
11356993l,
11477706l,
11592593l,
11592607l,
11592643l,
11592650l,
11618393l,
11618456l,
11619941l,
11618586l,
11623970l,
11664585l,
11677524l,
11677544l,
11677741l,
11678715l,
11678758l,
11678838l,
11679306l,
11679353l,
11679393l,11679455l);
for (Long id : ids) {
String url = String.format("/restaurant/%d/menu/list",id);
String _url = HOST_v1+url;
HttpHeaders httpHeaders = utility.getDefaultHeader();
HttpEntity requestMessage = new HttpEntity(httpHeaders);
// when
ResponseEntity response = restTemplate.exchange(
_url,
HttpMethod.GET,
requestMessage,
String.class);
// 음식점 정보들 파싱
Document doc = Jsoup.parse((String) response.getBody());
Element scriptElement = doc.getElementsByTag("script").get(2);
String innerJson = scriptElement.childNode(0).toString();
int start = innerJson.indexOf("window.__APOLLO_STATE__");
int end = innerJson.indexOf("window.__PLACE_STATE__");
// JSON으로 파싱
JSONObject target = new JSONObject(innerJson.substring(start,end).substring(25));
JSONArray jsonArray = target.names();
List<String> restaurantList = new ArrayList<>();
for (int i = 0; i < jsonArray.length(); i++) {
String possible = jsonArray.get(i).toString();
// 레스토랑 정보를 갖고 있는 곳은 RestaurantListSummary:XXXXXX 의 형태를 띄며 한번의 스크래핑에서 50개의 결과값이 나오게 된다
if(possible.contains("Menu") && Character.isDigit(possible.charAt(possible.length()-2))){
restaurantList.add(possible);
}
}
//
List<RestaurantDetailResponse> results = new ArrayList<RestaurantDetailResponse>();
for (String s : restaurantList) {
// 해당 JObject와 Response 객체간의 매핑
RestaurantDetailResponse mapped_data = gson.fromJson(target.get(s).toString(), RestaurantDetailResponse.class);
mapped_data.setImgUrl(String.valueOf(mapped_data.getImages().get("json")));
results.add(mapped_data);
}
}
굳이 방대한 양의 데이터를 넣어 테스트할 필요가 없으니 몇개만 추출하여 테스트를 진행합니다
String url = String.format("/restaurant/%d/menu/list",id)
if(possible.contains("Menu") && Character.isDigit(possible.charAt(possible.length()-2))){
restaurantList.add(possible);
}
mapped_data.setImgUrl(String.valueOf(mapped_data.getImages().get("json")));