Skip to content

Commit f773c59

Browse files
authored
Change to graphql api endpoint BirthdayCometRootQuery (#81)
Closes #24 (No locales being used anymore) Closes #32 (No async endpoint used so no domops) Closes #52 (No locales being used anymore) Closes #13 (No locales being used anymore)
1 parent de0de0d commit f773c59

9 files changed

Lines changed: 95 additions & 432 deletions

File tree

README.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,9 @@ Around 20 June 2019, Facebook removed their Facebook Birthday ICS export option.
1919
This change was unannounced and no reason was ever released.
2020

2121
fb2cal is a tool which restores this functionality.
22-
It works by calling various async endpoints that power the https://www.facebook.com/events/birthdays/ page.
22+
It works by calling endpoints that power the https://www.facebook.com/events/birthdays/ page.
2323
After gathering a list of birthdays for all the users friends for a full year, it creates a ICS calendar file. This ICS file can then be imported into third party tools (such as Google Calendar).
2424

25-
This tool **does not** use the Facebook API.
26-
2725
## Requirements
2826
* Facebook account
2927
* Python 3.6+
@@ -59,9 +57,6 @@ It is recommended to run the script **once every 24 hours** to update the ICS fi
5957
## Caveats
6058
* Facebook accounts secured with 2FA are currently not supported (see [#9](../../issues/9))
6159
* During Facebook authentication, a security checkpoint may trigger that will force you to change your Facebook password.
62-
* Some locales are currently not supported (see [#13](../../issues/13))
63-
* Some supported locales may fail. Consider changing your Facebook language to English temporarily as a workaround. (see [#52](../../issues/52))
64-
* Duplicate birthday events may appear if calendar is reimported after Facebook friends change their username due to performance optimizations. (see [#65](../../pull/65))
6560

6661
## Contributions
6762
Contributions are always welcome!

requirements.txt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
MechanicalSoup
22
ics>=0.6
3-
babel
4-
pytz
53
requests
6-
beautifulsoup4
7-
lxml
8-
python_dateutil
4+
lxml

src/birthday.py

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/facebook_browser.py

Lines changed: 34 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,15 @@
22
import re
33
import requests
44
import json
5-
from datetime import datetime
65
from logger import Logger
7-
from utils import get_next_12_month_epoch_timestamps, strip_ajax_response_prefix
8-
import urllib.parse
9-
from transformer import Transformer
106

117
class FacebookBrowser:
128
def __init__(self):
139
""" Initialize browser as needed """
1410
self.logger = Logger('fb2cal').getLogger()
1511
self.browser = mechanicalsoup.StatefulBrowser()
1612
self.browser.set_user_agent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36')
17-
self.__cached_async_token = None
13+
self.__cached_token = None
1814
self.__cached_locale = None
1915

2016
def authenticate(self, email, password):
@@ -76,47 +72,15 @@ def authenticate(self, email, password):
7672
self.logger.error(f'Hit Facebook security checkpoint. Please login to Facebook manually and follow prompts to authorize this device.')
7773
raise SystemError
7874

75+
def get_token(self):
76+
""" Get authorization token (CSRF protection token) that must be included in all requests """
7977

80-
def get_async_birthdays(self):
81-
""" Returns list of birthday objects by querying the Facebook birthday async page """
82-
83-
FACEBOOK_BIRTHDAY_ASYNC_ENDPOINT = 'https://www.facebook.com/async/birthdays/?'
84-
birthdays = []
85-
next_12_months_epoch_timestamps = get_next_12_month_epoch_timestamps()
86-
87-
transformer = Transformer()
88-
user_locale = self.get_facebook_locale()
89-
90-
for epoch_timestamp in next_12_months_epoch_timestamps:
91-
self.logger.info(f'Processing birthdays for month {datetime.fromtimestamp(epoch_timestamp).strftime("%B")}.')
92-
93-
# Not all fields are required for response to be given, required fields are date, fb_dtsg_ag and __a
94-
query_params = {'date': epoch_timestamp,
95-
'fb_dtsg_ag': self.get_async_token(),
96-
'__a': '1'}
97-
98-
response = self.browser.get(FACEBOOK_BIRTHDAY_ASYNC_ENDPOINT + urllib.parse.urlencode(query_params))
99-
100-
if response.status_code != 200:
101-
self.logger.debug(response.text)
102-
self.logger.error(f'Failed to get async birthday response. Params: {query_params}. Status code: {response.status_code}.')
103-
raise SystemError
104-
105-
birthdays_for_month = transformer.parse_birthday_async_output(response.text, user_locale)
106-
birthdays.extend(birthdays_for_month)
107-
self.logger.info(f'Found {len(birthdays_for_month)} birthdays for month {datetime.fromtimestamp(epoch_timestamp).strftime("%B")}.')
108-
109-
return birthdays
110-
111-
def get_async_token(self):
112-
""" Get async authorization token (CSRF protection token) that must be included in all async requests """
113-
114-
if self.__cached_async_token:
115-
return self.__cached_async_token
116-
117-
FACEBOOK_BIRTHDAY_EVENT_PAGE_URL = 'https://www.facebook.com/events/birthdays/' # async token is present on this page
118-
FACEBOOK_ASYNC_TOKEN_REGEXP_STRING = r'{\"token\":\".*?\",\"async_get_token\":\"(.*?)\"}'
119-
regexp = re.compile(FACEBOOK_ASYNC_TOKEN_REGEXP_STRING, re.MULTILINE)
78+
if self.__cached_token:
79+
return self.__cached_token
80+
81+
FACEBOOK_BIRTHDAY_EVENT_PAGE_URL = 'https://www.facebook.com/events/birthdays/' # token is present on this page
82+
FACEBOOK_TOKEN_REGEXP_STRING = r'{\"token\":\"(.*?)\"'
83+
regexp = re.compile(FACEBOOK_TOKEN_REGEXP_STRING, re.MULTILINE)
12084

12185
birthday_event_page = self.browser.get(FACEBOOK_BIRTHDAY_EVENT_PAGE_URL)
12286

@@ -132,49 +96,36 @@ def get_async_token(self):
13296
self.logger.error(f'Match failed or unexpected number of regexp matches when trying to get async token.')
13397
raise SystemError
13498

135-
self.__cached_async_token = matches[1]
99+
self.__cached_token = matches[1]
136100

137-
return self.__cached_async_token
101+
return self.__cached_token
138102

139-
def get_facebook_locale(self):
140-
""" Returns users Facebook locale """
141-
142-
if self.__cached_locale:
143-
return self.__cached_locale
103+
def query_graph_ql_birthday_comet_root(self, offset_month):
104+
""" Query the GraphQL BirthdayCometRootQuery endpoint that powers the https://www.facebook.com/events/birthdays page
105+
This endpoint will return all Birthdays for the offset_month plus the following 2 consecutive months. """
144106

145-
FACEBOOK_LOCALE_ENDPOINT = 'https://www.facebook.com/ajax/settings/language/account.php?'
146-
FACEBOOK_LOCALE_REGEXP_STRING = r'[a-z]{2}_[A-Z]{2}'
147-
regexp = re.compile(FACEBOOK_LOCALE_REGEXP_STRING, re.MULTILINE)
107+
FACEBOOK_GRAPHQL_ENDPOINT = 'https://www.facebook.com/api/graphql/'
108+
FACEBOOK_GRAPHQL_API_REQ_FRIENDLY_NAME = 'BirthdayCometRootQuery'
109+
DOC_ID = 3382519521824494
148110

149-
# Not all fields are required for response to be given, required fields are fb_dtsg_ag and __a
150-
query_params = {'fb_dtsg_ag': self.get_async_token(),
151-
'__a': '1'}
111+
variables = {
112+
'offset_month': offset_month,
113+
'scale': 1.5
114+
}
152115

153-
response = self.browser.get(FACEBOOK_LOCALE_ENDPOINT + urllib.parse.urlencode(query_params))
154-
155-
if response.status_code != 200:
156-
self.logger.debug(response.text)
157-
self.logger.error(f'Failed to get Facebook locale. Params: {query_params}. Status code: {response.status_code}.')
158-
raise SystemError
116+
payload = {
117+
'fb_api_req_friendly_name': FACEBOOK_GRAPHQL_API_REQ_FRIENDLY_NAME,
118+
'variables': json.dumps(variables),
119+
'doc_id': DOC_ID,
120+
'fb_dtsg': self.get_token(),
121+
'__a': '1'
122+
}
159123

160-
# Parse json response
161-
try:
162-
json_response = json.loads(strip_ajax_response_prefix(response.text))
163-
current_locale = json_response['jsmods']['require'][0][3][1]['currentLocale']
164-
except json.decoder.JSONDecodeError as e:
165-
self.logger.debug(response.text)
166-
self.logger.error(f'JSONDecodeError: {e}')
167-
raise SystemError
168-
except KeyError as e:
169-
self.logger.debug(json_response)
170-
self.logger.error(f'KeyError: {e}')
171-
raise SystemError
124+
response = self.browser.post(FACEBOOK_GRAPHQL_ENDPOINT, data=payload)
172125

173-
# Validate locale
174-
if not regexp.match(current_locale):
175-
self.logger.error(f'Invalid Facebook locale fetched: {current_locale}.')
126+
if response.status_code != 200:
127+
self.logger.debug(response.text)
128+
self.logger.error(f'Failed to get {FACEBOOK_GRAPHQL_API_REQ_FRIENDLY_NAME} response. Payload: {payload}. Status code: {response.status_code}.')
176129
raise SystemError
177-
178-
self.__cached_locale = current_locale
179-
180-
return self.__cached_locale
130+
131+
return response.json()

src/facebook_user.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class FacebookUser:
2+
def __init__(self, id, name, profile_url, profile_picture_uri, birthday_day, birthday_month):
3+
self.id = id
4+
self.name = name
5+
self.profile_url = profile_url
6+
self.profile_picture_uri = profile_picture_uri
7+
self.birthday_day = birthday_day
8+
self.birthday_month = birthday_month
9+
10+
def __str__(self):
11+
return f'{self.name} ({self.birthday_day}/{self.birthday_month})'
12+
13+
def __unicode__(self):
14+
return u'{self.name} ({self.birthday_day}/{self.birthday_month})'

src/fb2cal.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@
2323
import logging
2424
from distutils import util
2525

26-
from birthday import Birthday
2726
from ics_writer import ICSWriter
2827
from logger import Logger
2928
from config import Config
3029
from facebook_browser import FacebookBrowser
30+
from transformer import Transformer
3131

3232
if __name__ == '__main__':
3333
# Set CWD to script directory
@@ -62,18 +62,25 @@
6262
facebook_browser.authenticate(config['AUTH']['FB_EMAIL'], config['AUTH']['FB_PASS'])
6363
logger.info('Successfully authenticated with Facebook.')
6464

65-
# Get birthday objects for all friends via async endpoint
66-
logger.info('Fetching all Birthdays via async endpoint...')
67-
birthdays = facebook_browser.get_async_birthdays()
65+
# Fetch birthdays for a full calendar year and transform them
66+
facebook_users = []
67+
transformer = Transformer()
6868

69-
if len(birthdays) == 0:
70-
logger.warning(f'Birthday list is empty. Failed to fetch any birthdays.')
69+
# Endpoint will return all birthdays for offset_month plus the following 2 consecutive months.
70+
logger.info('Fetching all Birthdays via BirthdayCometRootQuery endpoint...')
71+
for offset_month in [1, 4, 7, 10]:
72+
birthday_comet_root_json = facebook_browser.query_graph_ql_birthday_comet_root(offset_month)
73+
facebook_users_for_quarter = transformer.transform_birthday_comet_root_to_birthdays(birthday_comet_root_json)
74+
facebook_users.extend(facebook_users_for_quarter)
75+
76+
if len(facebook_users) == 0:
77+
logger.warning(f'Facebook user list is empty. Failed to fetch any birthdays.')
7178
raise SystemError
7279

73-
logger.info(f'A total of {len(birthdays)} birthdays were found.')
80+
logger.info(f'A total of {len(facebook_users)} birthdays were found.')
7481

7582
# Generate ICS
76-
ics_writer = ICSWriter(birthdays)
83+
ics_writer = ICSWriter(facebook_users)
7784
logger.info('Creating birthday ICS file...')
7885
ics_writer.generate()
7986
logger.info('ICS file created successfully.')

src/ics_writer.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
""" Write Birthdays to an ICS file """
1313
class ICSWriter:
1414

15-
def __init__(self, birthdays):
15+
def __init__(self, facebook_users):
1616
self.logger = Logger('fb2cal').getLogger()
17-
self.birthdays = birthdays
17+
self.facebook_users = facebook_users
1818

1919
def generate(self):
2020
c = Calendar()
@@ -27,23 +27,23 @@ def generate(self):
2727

2828
cur_date = datetime.now()
2929

30-
for birthday in self.birthdays:
30+
for facebook_user in self.facebook_users:
3131
e = Event()
32-
e.uid = birthday.uid
32+
e.uid = facebook_user.id
3333
e.created = cur_date
34-
e.name = f"{birthday.name}'s Birthday"
34+
e.name = f"{facebook_user.name}'s Birthday"
3535

3636
# Calculate the year as this year or next year based on if its past current month or not
3737
# Also pad day, month with leading zeros to 2dp
38-
year = cur_date.year if birthday.month >= cur_date.month else (cur_date + relativedelta(years=1)).year
38+
year = cur_date.year if facebook_user.birthday_month >= cur_date.month else (cur_date + relativedelta(years=1)).year
3939

4040
# Feb 29 special case:
4141
# If event year is not a leap year, use Feb 28 as birthday date instead
42-
if birthday.month == 2 and birthday.day == 29 and not calendar.isleap(year):
43-
birthday.day = 28
42+
if facebook_user.birthday_month == 2 and facebook_user.birthday_day == 29 and not calendar.isleap(year):
43+
facebook_user.birthday_day = 28
4444

45-
month = '{:02d}'.format(birthday.month)
46-
day = '{:02d}'.format(birthday.day)
45+
month = '{:02d}'.format(facebook_user.birthday_month)
46+
day = '{:02d}'.format(facebook_user.birthday_day)
4747
e.begin = f'{year}-{month}-{day} 00:00:00'
4848
e.make_all_day()
4949
e.duration = timedelta(days=1)

0 commit comments

Comments
 (0)