Skip to content

Commit 5f6e313

Browse files
authored
Refactor code into multiple classes (#79)
1 parent 7c02f5d commit 5f6e313

8 files changed

Lines changed: 662 additions & 625 deletions

File tree

src/birthday.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
class Birthday:
2+
def __init__(self, uid, name, day, month):
3+
self.uid = uid # Unique identififer for person (required for ics events)
4+
self.name = name
5+
self.day = day
6+
self.month = month
7+
8+
def __str__(self):
9+
return f'{self.name} ({self.day}/{self.month})'
10+
11+
def __unicode__(self):
12+
return u'{self.name} ({self.day}/{self.month})'

src/config.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import configparser
2+
from logger import Logger
3+
4+
CONFIG_FILE_NAME = 'config.ini'
5+
CONFIG_FILE_PATH = f'../config/{CONFIG_FILE_NAME}'
6+
CONFIG_FILE_TEMPLATE_NAME = 'config-template.ini'
7+
8+
class Config:
9+
def __init__(self):
10+
self.logger = Logger('fb2cal').getLogger()
11+
self.config = configparser.RawConfigParser()
12+
13+
# Parse config
14+
try:
15+
dataset = self.config.read(CONFIG_FILE_PATH)
16+
if not dataset:
17+
self.logger.error(f'{CONFIG_FILE_PATH} does not exist. Please rename {CONFIG_FILE_TEMPLATE_NAME} if you have not done so already.')
18+
raise SystemExit
19+
except configparser.Error as e:
20+
self.logger.error(f'ConfigParser error: {e}')
21+
raise SystemExit
22+
23+
def getConfig(self):
24+
return self.config

src/facebook_browser.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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

Comments
 (0)