Enum 을 이용한 카테고리 정규화, 네이버 pcmap 스크래핑 테스트

JungWooLee·2022년 7월 23일
0

SpringBoot ToyProject

목록 보기
2/14

이어서 진행하기

Enum 을 이용한 카테고리별 서칭

앞서 진행한 프로젝트에서는 "한식" 을 카테고리로 갖는 음식점 정보를 갖고 오는 것을 테스트 하였습니다. 이번에는 네이버에서 제공하는 필터값을 사용하여 해당 필터에 해당되는 음식점 id 값들을 갖고 오도록 합니다

음식점 id ? : id값의 경우 이후 사용하게 될 메뉴정보와 기타 매장 정보를 스크래핑하기 위해 필요되는 필수 파라미터 값입니다. 이전에는 그저 음식점 위치와 이름등을 저장하였다면 id값을 통해 한번더 스크래핑하여 메뉴정보와 가격정보를 갖고 오도록 합니다

Restaurant ID 값을 이용한 세부정보 저장

먼저 음식점 선택의 기준이 될 수 있는 것에는 평점, 음식의 종류, 음식의 가격이 필수적이라고 생각되었습니다. 이에따라 세부 메뉴정보와 대표 메뉴를 선정하여 사용자가 가격 limit을 설정하게 되었을 때에 해당되는 정보만 DB에서 빼올 수 있도록 이러한 정보들을 스크래핑하게 됩니다


카테고리별 서칭하기

1. Enum 클래스 생성

@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 에 파라미터값이 입력 되는 것을 볼 수 있습니다.
양식, 한식으로 필터를 지정하였을 때 한식과 양식 둘중 하나라도 해당되는 음식점 정보가 매핑되는 것을 확인하였습니다

서비스를 만들게 될 때에 어차피 해당 부분은 따로 쿼리를 통하여 설정할것이기 때문에 네이버의 카테고리별로 음식들을 저장하도록 합니다

2. 테스트 : Enum 클래스를 바탕으로 테스트를 진행

	@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 값들을 뽑아 올 수 있는지 확인하였습니다


Restaurant ID 값을 이용한 세부정보 저장

1. 피들러를 통하여 graphql 을 통하여 정보반환이 가능한지 여부를 확인

일전에 했던 프로젝트에서도 다소의 삽질으로 html 을 파싱하여 레스토랑 정보를 갖고 오는 것을 했었는데 v1이후 v2를 통해 graphql 을 통한 json 데이터를 바로 받아올 수 있는 것을 확인한 이후 레스토랑 세부정보또한 이것이 가능한지 확인했습니다

피들러를 통해 분석하여 세부정보를 볼 수 있는 url 을 확인합니다
https://pcmap.place.naver.com/restaurant/{restaurantID}/home
해당 페이지로 접근시 html 로 페이지에 뿌려지는 것이 확인됩니다

메뉴 클릭시

리뷰 클릭시

menu 클릭시 graphql 이 호출되지 않는 것으로 보아 query를 통하여 json 데이터를 받는 것이 어렵다는 것을 확인 하였습니다

이에 따라 html 파싱으로 결정하게 되었습니다. (제발 DOM 이 중간에 바뀌지 않길 빌면서...)

2. 뽑아와야 할 데이터 ?

우선 방대한 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);
            }
        }

굳이 방대한 양의 데이터를 넣어 테스트할 필요가 없으니 몇개만 추출하여 테스트를 진행합니다

  • id 별로 받아와 url 을 통하여 호출 합니다 String url = String.format("/restaurant/%d/menu/list",id)
  • 서버랜더링 스크립트 인 window.APOLLO_STATE 의 JsonArray 값을 받기 위해 파싱합니다
  • 파싱이후 Menu{ID}_{index} 의 형태를 갖는 값만 리스트에 추가합니다
if(possible.contains("Menu") && Character.isDigit(possible.charAt(possible.length()-2))){
                    restaurantList.add(possible);
}
  • 이전에 만들어둔 모델과 매핑하고 json 오브젝트인 Images 같은 경우 메뉴 사진은 어차피 하나이기 때문에 매핑 후 따로 set 을 통하여 url 을 지정해줍니다
    mapped_data.setImgUrl(String.valueOf(mapped_data.getImages().get("json")));

0개의 댓글