사용자 영향력 프로젝트

[트위터] 데이터 수집기

appendonly 2022. 12. 17. 15:42

* 전부 Java로 구현함.

* tweepy와 커스텀 파이썬 크롤러는 API와 속도 차이가 너무 커서 포기함.

 

* 트위터는 bearerToken을 받아서 인증해야 API 사용 가능

* 아래는 API 호출 예시

static String bearerToken =  "AAAAAAAAAAAAAAAAAAAAAFJoWQEAAAAAtGXgIPTy1xrcTE5sXcVLmRU1KZ8%3DgdvzO1icW2UsKz0r6srlwfN8Cbu1Cl6eYLLKrgQUmBpY01zG0O";

static HttpClient httpClient = HttpClients.custom()
	        .setDefaultRequestConfig(RequestConfig.custom()
	            .setCookieSpec(CookieSpecs.STANDARD).build())
	        .build();

//요청 보낼 API URI
URIBuilder uriBuilder = new URIBuilder("https://api.twitter.com/2/users");
//쿼리 파라미터 자료형(실제 URI에 쿼리로 추가된다)
ArrayList<NameValuePair> queryParameters;
queryParameters = new ArrayList<>();
//add로 특정 Name의 Value를 추가. 아래는 ids라는 name에 사용자 id 배열을 지정한다.
queryParameters.add(new BasicNameValuePair("ids", String.join(",", ids)));
//user.fields라는 name에 username을 지정. user.fields 중 username을 얻어오겠다는 의미이다.
queryParameters.add(new BasicNameValuePair("user.fields", "username"));
uriBuilder.addParameters(queryParameters);

HttpGet httpGet = new HttpGet(uriBuilder.build());
//HTTP GET 메세지의 Authorization 헤더에 bearerToken을 지정한다.
httpGet.setHeader("Authorization", String.format("Bearer %s", bearerToken));
httpGet.setHeader("Content-Type", "application/json");

//http GET 요청을 보내고 그 결과를 response에 담음.
HttpResponse response = httpClient.execute(httpGet);
//response deserializing
HttpEntity entity = response.getEntity();
//빈게 아니라면 UTF-8 인코딩 문자열로 변환
if (null != entity) {
  userResponse = EntityUtils.toString(entity, "UTF-8");
}

//return 할 결과물
String[] ret = null;

if(userResponse!=null) {
	//응답 메세지를 JSON 객체화 및 data의 JSONArray를 추출함.
    //data와 message 두 개 존재했던 걸로 기억함.
    JSONArray res = new JSONObject(userResponse).getJSONArray("data");
    ret = new String[res.length()];
	
    //JSONArray인 data 내 각 JSON 객체마다 username을 추출함.
    for(int i=0;i<res.length();i++) {
        ret[i] = res.getJSONObject(i).getString("username");
    }
}

return ret;

 

* 데이터 개요

구분 개수
사용자 수 82,410
관계 수 6,641,814
트윗 수 17,108,019
연령 범위 1~15년
트윗 수집 기간 2021/09/09~2021/12/09

* 비공개 및 location이 확인되지 않는 사용자는 수집하지 않으므로 비영어권 사용자는 거의 없음.

 

* 구현 기능 및 부가 설명

이하 URIBuilder, queryParameters 등 리퀘스트마다 공통되는 부가 코드는 생략

 

1. getUserName(String[] ids)

public static String[] getUserName(String[] ids) throws IOException, URISyntaxException {}

String[] ids의 사용자들의 username을 얻어오는 함수이다. ids는 사용자 식별자이며 username은 사용자명이다. 위 예시 참고.

 

2. verifyID(String id)

public static boolean verifyID(String id) throws IOException, URISyntaxException {
    if(id==null)
        return false;

    String userResponse = null;
    JSONObject res;

    //URIBuilder, queryParameters 등 공통되는 부가 코드는 생략
    queryParameters.add(new BasicNameValuePair("ids", id));
    queryParameters.add(new BasicNameValuePair("user.fields", "created_at,description,pinned_tweet_id"));

    res = new JSONObject(userResponse);

    try {
        if(res.getJSONArray("errors").getJSONObject(0).getString("title").equals("Not Found Error"))
            return false;
    }catch(Exception e) {
        return true;
    }
}

매개변수로 주어진 id가 트위터 상 실제 존재하는 id인지 확인하는 함수이다.

 

3. getFriends(String id)

public static String[] getFriends(String id) throws Exception {
    System.out.println("Getting friends of "+id);
    String userResponse = null;
    String[] ret;
	
    URIBuilder uriBuilder = new URIBuilder("https://api.twitter.com/1.1/friends/ids.json");
    
    queryParameters.add(new BasicNameValuePair("id", id));
    //id가 자료형 범위 밖일 경우를 대비해 String으로 반환하도록 요청하는 헤더
    queryParameters.add(new BasicNameValuePair("stringify_ids", "true"));
    //queryParameters.add(new BasicNameValuePair("count", "100"));

    JSONArray res = null;
    JSONObject obj = new JSONObject(userResponse);
    System.out.println();
    res = (JSONArray) obj.get("ids");

    ret = new String[res.length()];
    Iterator<Object> iterator = res.iterator();
    int i=0;
    while(iterator.hasNext()) ret[i++] = (String) iterator.next();

    return ret;
}

id에 해당되는 사용자가 팔로우하는 사용자들의 id들을 반환하는 함수이다. 기본 5000명씩이었던 것으로 기억.

 

4. multiUsers(String[] ids)

public static String[] multiUsers(String[] ids) throws IOException, URISyntaxException {
		if(ids==null) return null;
        
		String userResponse = null;
		String[] ret;
		
        //매개변수 ids를 리퀘스트 크기 제한에 맞춰 100개씩 요청하도록 분할
		if(ids.length>100) {
			List<String> tmp = (List<String>)Arrays.asList(ids);
			List<String> result = new ArrayList<>();
			
			List<List<String>> chunked_tmp = chunkedLists(tmp, 100);
			
			for(List<String> lst:chunked_tmp) {
				String[] arr = lst.toArray(new String[0]);
				String[] test = multiUsers(arr);
				List<String> smth = null;
				
				if(test!=null) {
					smth = Arrays.asList(test);
				}
				
				if(smth!=null)	result.addAll(smth);
			}
            //안에 new String[0] 넣어줘야 Object[]에서 String[]으로 변환
            //아래가 이 함수의 최종 결과를 반환하는 부분이다.
			return result.toArray(new String[0]);
		}
		
		URIBuilder uriBuilder = new URIBuilder("https://api.twitter.com/2/users");
	    queryParameters.add(new BasicNameValuePair("ids", String.join(",", String.join(",", ids))));
	    queryParameters.add(new BasicNameValuePair("user.fields", "created_at,description,pinned_tweet_id"));

	    JSONArray res = null;
	    JSONObject obj = new JSONObject(userResponse);
	    
		try {
        	//json 배열이 data랑 error 항목으로 구성돼서 data만 참고하면 없는 사용자를 참고하는 일은 없음.
			res = obj.getJSONArray("data");
			System.out.println(obj);
		}catch(Exception e) {
			System.out.println("<Exception>\n"+obj);
			//e.printStackTrace();
			return null;
		}
		
		ret = new String[res.length()];
		
		for(int i=0;i<res.length();i++) ret[i] = res.getJSONObject(i).getString("id");
		
		return ret;
	}

주어진 목록의 id들 중 존재하지 않는 id들은 제거하고 존재하는 id들만 남겨서 반환하는 함수이다.

 

5. chunkedLists(List<T> list, final int chunkSize)

public static <T> List<List<T>> chunkedLists(List<T> list, final int chunkSize) {
    if (list == null)
        throw new IllegalArgumentException("Input list must not be null");
    if (chunkSize <= 0)
    	throw new IllegalArgumentException("Chunk Size must be > 0");

    List<List<T>> subLists = new ArrayList<List<T>>();
    final int listSize = list.size();
    
    for (int i = 0; i < listSize; i += chunkSize)
      subLists.add(new ArrayList<T>(list.subList(i, Math.min(listSize, i + chunkSize))));

    return subLists;
}

리스트를 chunkSize 크기의 부분 리스트들로 분할하는 함수이다.

 

6. getUsers(String[] usernames, String fileName)

public static String[] getUsers(String[] usernames, String fileName) throws IOException, URISyntaxException, InterruptedException {
    String userResponse = null;
    String[] ret;
    FileWriter myWriter = new FileWriter("C:/Users/PC/Desktop/"+fileName+".txt");
    
    //chunkify id list by 100, since API only allows 100 for each request.
    if(usernames.length>100) {
        List<String> tmp = (List<String>)Arrays.asList(usernames);
        List<String> result = new ArrayList<>();

        List<List<String>> chunked_tmp = chunkedLists(tmp, 100);

        for(List<String> lst:chunked_tmp) {
            String[] arr = lst.toArray(new String[0]);
            System.out.println("Array: "+Arrays.toString(arr));
            String[] test = getUsers(arr, fileName);
            List<String> smth = null;

            if(test!=null) {
                smth = Arrays.asList(test);
            }else {
                test = getUsers(arr, fileName);
                if(test == null) continue;
            }

            if(smth!=null)	result.addAll(smth);
        }
		
        //id와 생성일자 매핑 함수
        System.out.println("Printing hashmap.");
        System.out.println("Hashmap Size:"+hm.keySet().size());

        for(String key:hm.keySet()) {
            myWriter.write(key+" "+hm.get(key)+"\n");
        }
		
        //'id 생성일자'를 파일에 기록
        myWriter.flush();
        myWriter.close();
        return result.toArray(new String[0]);
    }

    queryParameters.add(new BasicNameValuePair("ids", String.join(",", String.join(",", usernames))));
    queryParameters.add(new BasicNameValuePair("user.fields", "created_at,description,pinned_tweet_id"));

    JSONArray res = null;
    JSONObject obj = new JSONObject(userResponse);
    System.out.println(obj);
    
    try {
    	//json 배열이 data랑 error 항목으로 구성돼서 data만 참고하면 없는 사용자를 참고하는 일은 없음.
        res = obj.getJSONArray("data");
    }catch(Exception e) {
        e.printStackTrace();
        //API 시간 제한으로 대기
        Thread.sleep(15*60*1000+1000);
        return null;
    }

    ret = new String[res.length()];

    for(int i=0;i<res.length();i++) {
        String date = res.getJSONObject(i).getString("created_at").substring(0, 4);
        hm.put(res.getJSONObject(i).getString("id"), date);
        ret[i] = date;
    }

    return ret;
}

사용자 id와 생성일자를 매핑하여 text file에 기록하는 함수이다.

 

7. getTweets(String userId, String nextToken, CSVWriter csv)

public static String getTweets(String userId, String nextToken, CSVWriter csv) throws IOException, URISyntaxException, InterruptedException {
    String tweetResponse = null;
    boolean sw = false;
    
    URIBuilder uriBuilder = new URIBuilder(String.format("https://api.twitter.com/2/users/%s/tweets", userId));
    
    queryParameters = new ArrayList<>();
    queryParameters.add(new BasicNameValuePair("max_results", "100"));
    queryParameters.add(new BasicNameValuePair("start_time", "2021-09-04T00:00:00Z"));
    queryParameters.add(new BasicNameValuePair("end_time", "2021-12-04T00:00:00Z"));
    queryParameters.add(new BasicNameValuePair("tweet.fields", "created_at,public_metrics"));
    queryParameters.add(new BasicNameValuePair("exclude", "replies"));
    if(nextToken!=null) queryParameters.add(new BasicNameValuePair("pagination_token", nextToken));

    while(true) {
        HttpResponse response = httpClient.execute(httpGet);
        HttpEntity entity = response.getEntity();
        
        if (null != entity) {
          tweetResponse = EntityUtils.toString(entity, "UTF-8");
        }

        JSONArray res = null;
        JSONObject obj = new JSONObject(tweetResponse);

        try {
        	//no tweets
            if(obj.getJSONObject("meta").getInt("result_count")==0) return null;
            res = obj.getJSONArray("data");
        }catch(Exception e) {
            if(obj.has("errors")) {
                String tmp = (String)((JSONObject)obj.getJSONArray("errors").get(0)).get("detail");

                if(((tmp.equals("Sorry, you are not authorized to see the user with id: ["+userId+"].")
                        || tmp.equals("Could not find user with id: ["+userId+"].")
                        || tmp.equals("User has been suspended: ["+userId+"].")))) return null;
            }
			
            //True if 한차례 대기했음 else False
            if(sw == false) {
                System.out.println("Thread sleeps until "+LocalDateTime.now().plusMinutes(15).plusSeconds(10));
                Thread.sleep(15*60*1000+1000);
            }
            else return null;

            sw = true;
        }
		
        //트윗 정보 하나씩 입력해나감.
        for(int i=0;i<res.length();i++) {
            JSONObject object = res.getJSONObject(i);
            JSONObject metrics = object.getJSONObject("public_metrics");

            csv.writeNext(new String[]{userId,object.getString("created_at"),object.getString("id"),object.getString("text").replaceAll("[\\n\\t,]", "").replaceAll(",", " "),String.valueOf(metrics.getInt("retweet_count")),String.valueOf(metrics.getInt("like_count")),String.valueOf(metrics.getInt("reply_count")),String.valueOf(metrics.getInt("quote_count"))});
        }

        csv.flush();

        //fetch next token for pagination.
        try{nextToken = obj.getJSONObject("meta").getString("next_token");}
        catch(Exception e) {return null;}
		
        //이 부분을 return 하는 걸로 바꾸고 다음 호출 때 넘기는 식으로 구현 바람.
        //재귀 깊이가 계속 쌓여감.
        if(nextToken!=null) getTweets(userId, nextToken, csv);
        return null;
    }
}