트위터에서 많은 팔로워를 크롤링하는 방법 [3]: 1초에 5천개 데이터 가져오기

트위터에서 많은 팔로워를 크롤링하는 방법 [3]: 1초에 5천개 데이터 가져오기

앞서 쓴 글(2편)에서는 Twint와 직접 만든 크롤러를 이용해 데이터를 수집하는 내용을 정리했다. 하지만 여전히 큰 문제가 남아있었다.

“1만명까지는 어떻게든 모을 수 있다. 그런데 10만 이상의 팔로워/팔로잉을 가진 유저의 데이터는 대체 어떻게 가져와야 하나?”

여기서는 몇 가지의 이슈가 충돌했다.

  1. “여러 계정을 한번에 가져와야 한다.”: 수집해야하는 계정이 여전히 1k+개.
  2. “15분 이상의 긴 수집 시간이 필요하다.”: Lambda에서는 어렵다.
  3. “한 IP에서 너무 많은 요청을 하면 안된다.”: 한 IP에서 너무 많은 요청을 하면 걸릴 수 있다.
  4. “HTML 웹 크롤링은 간혹 일부만 수집후 종료되더라.”: 트위터에서 간혹 팔로잉/팔로워를 모두 수집하지 않았음에도 (5만명중 1만명만 수집 등) 이후 Cursor를 제공하지 않아 크롤링이 정지되는 경우가 있었다.

그러다, 이 이슈를 한번에 해결할 수 있는 방법(꼼수)를 찾게 되었다.

어떤 방법인가?

꼼수아닌 꼼수는 아래와 같은 과정을 통해 발견할 수 있었다.

“트위터에 로그인 한 상태로 ‘더보기’ 버튼을 누르면 무한히 내려간다. 근데 이건 ajax요청인데?”

⬇️

“어? 어떤 API를 사용하는거지?”

⬇️

“트위터 개발자 API와 같은 API네?”

⬇️

“개발자 등록을 해서 쓸 때는 15분에 15개 요청 제한인데, 지금은 100번 넘게 해도 잘 가져오네?”

개발자 등록을 해서 얻는 OAuth API Key로는 곧바로 제한이 걸려버리는데, 정작 일반 트위터 유저로 로그인한 상태에서 요청을 하는 것에는 제한이 없었던 것!

Q. 그렇다면 제한/한계는 없나?

A. 같은 API 엔드포인트를 사용하는 만큼, 요청 QueryString에 넣은 쿼리는 트위터 개발자용 API와 100% 호환된다.

이후 서술하겠지만, 아래의 조건을 지키면 문제가 생기지 않는다.

  1. 정상적으로 인증받은(이메일 & 전화) 트위터 계정을 이용하기
    ➡️ 인증받지 않은 계정을 하면 요청이 금방 막힌다.
  2. 한번에 하나의 요청만 진행할 것
    ➡️ Multiprocess등을 이용해 동시 요청을 하는 순간 바로 막힌다.

즉, 1개의 PC에서 한번에 1번의 요청만 보내면 꾸준히 요청이 가능하다는 것이다.

상당히 쉬운 조건이다!

1st try: 유저들의 모든 정보를 가져와보자!

1st-1) 웹 사이트를 뜯어보자

💥 Spoilers 💥

이 방법은 한번에 200개가 최대이지만, 아래 2nd try에서는 한번에 5000개를 가져옵니다.

컨셉은 “로그인이 유지된 상태의 HTTP 요청을 따라하기” 이기 때문에, 트위터에 로그인을 한 상태에서 비동기 요청을 보내는 것을 크롬으로 몰래 가져오면 된다.

Followers 혹은 Following으로 들어가서 스크롤 몇 번을 해주면 스크린샷 우측과 같이 followers/list.json API로 요청하는 것을 볼 수 있다.

Twitter 트위터의 팔로워들

해당 내용을 살펴보면 아래 스크린샷과 같이 각 유저들의 디테일한 정보를 가져오는 것을 볼 수 있다.

트위터가 Following하는 계정 중 하나.<br/>Id, name, screen_name 등의 프로필 정보를 상당히 디테일하게 가져온다.

위 정보 중에서 사실 필요한 것은 id, 그리고 screen_name 정보다.

  • id: 트위터 각 계정의 고유한 No. (변경 불가능)
  • screen_name: 트위터에 로그인할때 & @Mention 기능을 사용할때 쓰는 아이디. (변경 가능)

그래도 다른 정보도 같이 있으면 좋긴 하니까, 모두 그대로 가져오기로 생각했다.

1st-2) HTTP Request 코드를 만들자

크롬에서 HTTP 요청이 있다면 곧바로 Python의 requests 기반 요청으로 바꿀 수 있다.

크롬 네트워크 탭에서 HTTP 요청에 우클릭

위 과정을 입력하면 해당 HTTP 요청이 cURL 형식으로 복사된다.

복사한 값을 https://curl.trillworks.com/ 에 가서 그대로 붙여넣기를 해주자.

curl.trillworks.com에서는 cURL을 Python코드로 바꿔준다.

붙여넣기를 완료한 파이썬 코드는 아래와 같은 결과가 나온다.

[Note] 아래 token들은 원래 각자 다른 값이 들어있다.

개인정보 보호를 위해 testToken이라는 이름으로 일괄 숨김을 진행한 것이라, 원래는 영문자와 숫자의 복잡한 값으로 이루어져있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import requests

cookies = {
'dnt': '1',
'kdt': 'testToken',
'csrf_same_site_set': '1',
'csrf_same_site': '1',
'syndication_guest_id': 'testToken',
'_ga': 'testToken',
'personalization_id': 'testToken==',
'guest_id': 'v1%testToken',
'ads_prefs': 'testToken=',
'remember_checked_on': '1',
'u': 'testToken',
'auth_token': 'testToken',
'twid': 'u%testToken',
'rweb_optin': 'side_no_out',
'tfw_exp': '0',
'des_opt_in': 'N',
'night_mode': '0',
'ct0': 'testToken',
}

headers = {
'Connection': 'keep-alive',
'Pragma': 'no-cache',
'Cache-Control': 'no-cache',
'Origin': 'https://twitter.com',
'x-twitter-client-language': 'en',
'x-csrf-token': 'testtoken',
'authorization': 'Bearer testToken',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36',
'x-twitter-auth-type': 'OAuth2Session',
'x-twitter-active-user': 'yes',
'DNT': '1',
'Accept': '*/*',
'Sec-Fetch-Site': 'same-site',
'Sec-Fetch-Mode': 'cors',
'Referer': 'https://twitter.com/twitter/followers',
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
}

params = (
('include_profile_interstitial_type', '1'),
('include_blocking', '1'),
('include_blocked_by', '1'),
('include_followed_by', '1'),
('include_want_retweets', '1'),
('include_mute_edge', '1'),
('include_can_dm', '1'),
('include_can_media_tag', '1'),
('skip_status', '1'),
('cursor', '1653632830146283795'),
('user_id', '783214'),
('count', '20'),
)

response = requests.get('https://api.twitter.com/1.1/followers/list.json', headers=headers, params=params, cookies=cookies)

# 결과는 response.json()
result = response.json()
print(result)

위 코드에서 주목해야 하는 부분은 바로 params, 즉 44번째줄의 인자 부분이다. 해당 부분 값 중에서 아래의 3가지 항목이 우리가 변경해야 하는 값이다.

1
2
3
('cursor', '1653632830146283795'),
('user_id', '783214'),
('count', '20'),
  • cursor: 요청을 하는 요청 시점. -1 을 넣어주면 첫 요청이 되고, 1653632830146283795 처럼 숫자가 들어가있으면 DB 커서처럼 해당 부분부터 count 갯수만큼 반환된다.
  • user_id: 해당 유저의 ID.
  • count: 한번에 몇개의 결과를 반환할지. 이 부분은 max 200이다.

따라서, 아래와 같은 로직의 요청을 진행하면 된다.

  1. user_id 를 지정하고, count 를 200으로 설정한 뒤, cursor-1 로 맞추고 요청을 시작한다.
  2. Response를 .json() 을 이용해 값을 얻어내고, 해당 값 중 cursor 정보를 얻어낸다.
  3. 결과값을 while True: 를 돌면서 cursor 값을 업데이트하면서 Response에서 cursor 가 더이상 등장하지 않을때까지 반복한다.

이정도면, 꽤 괜찮은 속도로 수집이 가능하다. 😄

1st-3) user_id 대신 트위터 ID인 screen_name 으로 가져오자

하지만 위 요청에서는 한가지 한계가 있다. 바로 user_id 값을 알아내야 한다는 것.

[Note] 만약 유저들의 user_id 를 알고있다면, 해당 값을 기준으로 수집하는 것이 가장 안전하고 정확하다. screen_name 은 유저들이 언제든지 변경 가능하기에, 안정적인 고유 식별자가 아니다.

우리가 보통 트위터에서 쉽게 눈으로 보고 얻을 수 있는 정보는 screen_name, 즉 @ 로 시작하는 유저의 로그인 아이디이기 때문에 해당 screen_name 값을 통해 가져온다면 좀더 편할 것 같았다.

Q. 혹시 Parameter를 user_id 대신 screen_name 으로 바꿔도 동작할까?

A. 잘 동작합니다!

아래와 같이 params 내용의 값을 바꾸어주면 잘 동작한다.

1
2
3
('cursor', '1653632830146283795'),
('screen_name', 'twitter'), # <-- 이 부분이 바뀌었다!
('count', '200'),

와우! 이제 수집만 하면 될 것 같았다.

하지만, 1만 팔로워 넘는 셀럽이 너무 많더라. 😨

2nd try: 팔로워 수집, ID만 가져오면 어떨까? 🎉

1st try에서 진행했던 방식으로 열심히 크롤링을 하던 중, 한번의 요청에 약 1-1.5s가 걸리는 것을 지켜보면서 10만 Follow를 수집하려면 대략 500-750초가 걸리겠구나.. 하고 생각하고 있었다. 나쁘지 않은 속도, 하지만 크롤링 남은 시간이 800시간인 것을 보면 대략 정신이 멍해진다.

다른 방법을 찾아야 했다.

그러던 중, 이런 의문이 들었다.

“음… 혹시 똑같은 Auth Token으로 다른 API를 쓸 수는 없을까?”

답은 놀랍게도, 가능하다!

트위터 웹에서 사용하는 /followers/list.json 뿐만 아니라 다른 API도 호출이 가능했던 것.

2nd-1) ids.json API가 있네..?

새로 사용해보는 API는 Follower들의 id 만 목록으로 받아내는 API였다.

공식 문서는 ➡️ https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-ids

앞서 사용한 list.json API가 팔로워들의 모든 정보(팔로우 수, BIO, 계정명, ID, …)를 가져오는 것에 반해, ids.json API는 단순히 Id 하나만을 리스트로 툭 하고 반환할 뿐이다.

대신, 한번에 5000개의 값을 반환해준다. 🎉

하지만 나에게 필요했던 것은 단순히 팔로우-팔로잉 관계만 알아내면 되는 것이기 때문에, 다른 정보는 필요하지 않았다.

따라서 대박이다! 를 외치고 작업을 시작했다.

2nd-2) ids.json API 요청은 어떻게 하나?

99% 동일하다. (사실상 같다.)

요청시 API 주소만 다르게 하고, Params만 아래와 같이 입력해주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# ... 생략 ...

params = (
('include_profile_interstitial_type', '1'),
('include_blocking', '1'),
('include_blocked_by', '1'),
('include_followed_by', '1'),
('include_want_retweets', '1'),
('include_mute_edge', '1'),
('include_can_dm', '1'),
('include_can_media_tag', '1'),
('skip_status', '1'),
('cursor', cursor),
('user_id', user_id), # <-- screen_name으로 대체가능
('count', '5000'), # <-- max 5000
)

response = requests.get('https://api.twitter.com/1.1/folloewrs/ids.json', headers=headers, params=params)

params의 count 를 무려 5000으로 올릴 수 있다는 것이다.

2nd-3) 모든 팔로워/팔로잉을 가져올때 까지 가져오는 함수를 만들자.

앞서 진행한 로직과 같이 while True: 를 돌면서 cursor 값이 0 이 될 때까지 수집을 진행하면 된다.

아래 코드는 크게 두 함수로 만들어져있다.

  1. get_follow_ids: user_idusername(screen_name), cursor를 넣으면 해당 요청의 결과(json)을 반환하는 함수.
  2. get_follow_all: user_idusername(screen_name), user_follow_count(유저의 팔로우 수, Optional)를 넣으면 전체 결과를 반환하는 함수.

아래 코드 내 [TESTTOKEN] 으로 된 부분만 앞서 체크한 cURL 요청 변환에서 찾아 넣어주면, 성공적으로 요청을 진행할 수 있다.

[Note] Cookie 부분은 앞서 나온 코드처럼 cookie=cookie 인자로 전달해줘도 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import requests
import json
from bs4 import BeautifulSoup as bs

def get_follow_ids(user_id, username, cursor=-1):
headers = {
'authority': 'api.twitter.com',
'origin': 'https://twitter.com',
'x-twitter-client-language': 'ko',
'x-csrf-token': '[TESTTOKEN]',
'authorization': 'Bearer [TESTTOKEN]',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36',
'x-twitter-auth-type': 'OAuth2Session',
'x-twitter-active-user': 'yes',
'dnt': '1',
'accept': '*/*',
'sec-fetch-site': 'same-site',
'sec-fetch-mode': 'cors',
'referer': f'https://twitter.com/{username}/following',
'accept-encoding': 'gzip, deflate, br',
'accept-language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
'cookie': 'personalization_id="[TESTTOKEN]"; guest_id=v1%[TESTTOKEN]; ct0=[TESTTOKEN]; ads_prefs="[TESTTOKEN]="; kdt=[TESTTOKEN]; remember_checked_on=1; _twitter_sess=[TESTTOKEN]; auth_token=[TESTTOKEN]; csrf_same_site_set=1; rweb_optin=side_no_out; csrf_same_site=1; twid=u%[TESTTOKEN]',
}

params = (
('include_profile_interstitial_type', '1'),
('include_blocking', '1'),
('include_blocked_by', '1'),
('include_followed_by', '1'),
('include_want_retweets', '1'),
('include_mute_edge', '1'),
('include_can_dm', '1'),
('include_can_media_tag', '1'),
('skip_status', '1'),
('cursor', cursor),
('user_id', user_id),
('count', '5000'),
)

response = requests.get('https://api.twitter.com/1.1/followers/ids.json', headers=headers, params=params)
return response.json()

def get_follow_all(user_id, username, user_follow_count=None):
cursor = -1
dataset = []

while cursor != 0: # 커서가 0이면 다음페이지가 없다.
data = get_follow_ids(user_id, username, cursor)
try:
cursor = data['next_cursor']
except KeyError as e:
print(data)
if data.get('error') and data['error'] == 'Not authorized.':
open(f"protected/{user_id}", 'w').close()
print(username, "Protected")
return
if data.get('errors') and data['errors'][0]['code'] == 34:
open(f"deleted/{user_id}", 'w').close()
print(username, "Does Not exist")
return
print('='*50)
print('Username:', username, 'User_id', user_id)
print(data)
print('='*50)
raise e
dataset += data['ids']
print(f'{username}({user_id}): {len(set(dataset))} / {user_follow_count}', end='\r')
dataset = list(set(dataset))
json.dump(dataset, open(f'success/{user_id}.json', 'w+'))
print(f'# Final for USER [{username}({user_id})]: {len(dataset)} / {user_follow_count}')

온전한 Following(팔로잉)을 가져오는 코드는 아래 Github Gist에서 찾을 수 있습니다.

https://gist.github.com/Beomi/9d263bf9d1128180e1c17c1e94b0409b

이제 위 함수를 user_idfor loop를 돌며 실행하면 API의 응답에 따라 proetectd, deleted, success 등의 폴더에 자동으로 정리된다. (당연히 각 폴더는 미리 생성해두어야 에러가 나지 않는다.)

이번 요청 역시 하나에 1~1.5s가 걸리지만, 앞서 200개 대비 무려 25배 빨라지기 때문에 훨씬 편안한 수집이 가능하다.

맺으며

트위터에서 RT가 어떻게 퍼지는지 Network Tree를 그리기 위해, 엄밀하고 정확한 수준의 팔로워-팔로잉 그래프가 필요했다.

앞선 시행착오를 통해 5k+ 유저들의 경우 이 방식으로 수집하고, 그 이하의 경우는 앞서 만든 병렬 처리 가능한 HTML 크롤러로 나머지 수집을 진행했다.

약 20만명+ 수준을 수집할 수 있었고, 실제로 5k+ 유저들을 모으는데 걸리는 시간이 그 이하를 AWS lambda를 통해 병렬수집하는 시간보다 훨씬 오래 걸렸다.

이후 idscreen_name 의 중복 사용 혹은 변경 등을 체크해 맞는지/아닌지를 검사해 팔로우/팔로워 수집 태스크를 마쳤다.

앞으로 트위터에서 어떻게 대응할지는 모르겠지만, API의 한계를 이런 식으로 넘을 수 있어서 정말 다행히도 프로젝트 진행이 가능했다. 😂

결론: 트위터 API는 돈주고 쓰는게 마음편하다…..

다음부터 가능하면 결제 펑펑 할 수 있기를 바라며, 글을 마친다.

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×