1+ import mechanicalsoup
2+ import re
3+ import requests
4+ import json
5+ from datetime import datetime
6+ 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
10+
11+ class FacebookBrowser :
12+ def __init__ (self ):
13+ """ Initialize browser as needed """
14+ self .logger = Logger ('fb2cal' ).getLogger ()
15+ self .browser = mechanicalsoup .StatefulBrowser ()
16+ 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
18+ self .__cached_locale = None
19+
20+ def authenticate (self , email , password ):
21+ """ Authenticate with Facebook setting up session for further requests """
22+
23+ FACEBOOK_LOGIN_URL = 'http://www.facebook.com/login.php'
24+ FACEBOOK_DATR_TOKEN_REGEXP = r'\"_js_datr\",\"(.*?)\"'
25+ regexp = re .compile (FACEBOOK_DATR_TOKEN_REGEXP , re .MULTILINE )
26+
27+ # Add 'datr' cookie to session for countries adhering to GDPR compliance
28+ login_page = self .browser .get (FACEBOOK_LOGIN_URL )
29+
30+ if login_page .status_code != 200 :
31+ self .logger .debug (login_page .text )
32+ self .logger .error (f'Failed to authenticate with Facebook with email { email } . Stage: Initial Request for datr Token, Status code: { login_page .status_code } .' )
33+ raise SystemError
34+
35+ matches = regexp .search (login_page .text )
36+
37+ if not matches or len (matches .groups ()) != 1 :
38+ self .logger .debug (login_page .text )
39+ self .logger .error (f'Match failed or unexpected number of regexp matches when trying to get datr token.' )
40+ raise SystemError
41+
42+ _js_datr = matches [1 ]
43+
44+ datr_cookie = requests .cookies .create_cookie (domain = '.facebook.com' , name = 'datr' , value = _js_datr )
45+ _js_datr_cookie = requests .cookies .create_cookie (domain = '.facebook.com' , name = '_js_datr' , value = _js_datr )
46+ self .browser .get_cookiejar ().set_cookie (datr_cookie )
47+ self .browser .get_cookiejar ().set_cookie (_js_datr_cookie )
48+
49+ # Perform main login now
50+ login_page = self .browser .get (FACEBOOK_LOGIN_URL )
51+
52+ if login_page .status_code != 200 :
53+ self .logger .debug (login_page .text )
54+ self .logger .error (f'Failed to authenticate with Facebook with email { email } . Stage: Main Login Attempt, Status code: { login_page .status_code } .' )
55+ raise SystemError
56+
57+ login_form = login_page .soup .find ('form' , {'id' : 'login_form' })
58+ login_form .find ('input' , {'id' : 'email' })['value' ] = email
59+ login_form .find ('input' , {'id' : 'pass' })['value' ] = password
60+ login_response = self .browser .submit (login_form , login_page .url )
61+
62+ if login_response .status_code != 200 :
63+ self .logger .debug (login_response .text )
64+ self .logger .error (f'Failed to authenticate with Facebook with email { email } . Stage: Main Login Reponse, Status code: { login_response .status_code } .' )
65+ raise SystemError
66+
67+ # Check to see if login failed
68+ if login_response .soup .find ('link' , {'rel' : 'canonical' , 'href' : 'https://www.facebook.com/login/' }):
69+ self .logger .debug (login_response .text )
70+ self .logger .error (f'Failed to authenticate with Facebook with email { email } . Please check provided email/password.' )
71+ raise SystemError
72+
73+ # Check to see if we hit Facebook security checkpoint
74+ if login_response .soup .find ('button' , {'id' : 'checkpointSubmitButton' }):
75+ self .logger .debug (login_response .text )
76+ self .logger .error (f'Hit Facebook security checkpoint. Please login to Facebook manually and follow prompts to authorize this device.' )
77+ raise SystemError
78+
79+
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 )
120+
121+ birthday_event_page = self .browser .get (FACEBOOK_BIRTHDAY_EVENT_PAGE_URL )
122+
123+ if birthday_event_page .status_code != 200 :
124+ self .logger .debug (birthday_event_page .text )
125+ self .logger .error (f'Failed to retreive birthday event page. Status code: { birthday_event_page .status_code } .' )
126+ raise SystemError
127+
128+ matches = regexp .search (birthday_event_page .text )
129+
130+ if not matches or len (matches .groups ()) != 1 :
131+ self .logger .debug (birthday_event_page .text )
132+ self .logger .error (f'Match failed or unexpected number of regexp matches when trying to get async token.' )
133+ raise SystemError
134+
135+ self .__cached_async_token = matches [1 ]
136+
137+ return self .__cached_async_token
138+
139+ def get_facebook_locale (self ):
140+ """ Returns users Facebook locale """
141+
142+ if self .__cached_locale :
143+ return self .__cached_locale
144+
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 )
148+
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' }
152+
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
159+
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
172+
173+ # Validate locale
174+ if not regexp .match (current_locale ):
175+ self .logger .error (f'Invalid Facebook locale fetched: { current_locale } .' )
176+ raise SystemError
177+
178+ self .__cached_locale = current_locale
179+
180+ return self .__cached_locale
0 commit comments