calendar_event_engine 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. calendar_event_engine-0.1.0/LICENSE +28 -0
  2. calendar_event_engine-0.1.0/PKG-INFO +57 -0
  3. calendar_event_engine-0.1.0/README.md +28 -0
  4. calendar_event_engine-0.1.0/calendar_event_engine/Runner.py +169 -0
  5. calendar_event_engine-0.1.0/calendar_event_engine/__init__.py +0 -0
  6. calendar_event_engine-0.1.0/calendar_event_engine/db_cache.py +147 -0
  7. calendar_event_engine-0.1.0/calendar_event_engine/filter.py +28 -0
  8. calendar_event_engine-0.1.0/calendar_event_engine/logger.py +10 -0
  9. calendar_event_engine-0.1.0/calendar_event_engine/parser/__init__.py +0 -0
  10. calendar_event_engine-0.1.0/calendar_event_engine/parser/package.py +58 -0
  11. calendar_event_engine-0.1.0/calendar_event_engine/parser/submission.py +51 -0
  12. calendar_event_engine-0.1.0/calendar_event_engine/publishers/__init__.py +0 -0
  13. calendar_event_engine-0.1.0/calendar_event_engine/publishers/abc_publisher.py +60 -0
  14. calendar_event_engine-0.1.0/calendar_event_engine/publishers/mobilizon/__init__.py +0 -0
  15. calendar_event_engine-0.1.0/calendar_event_engine/publishers/mobilizon/api.py +123 -0
  16. calendar_event_engine-0.1.0/calendar_event_engine/publishers/mobilizon/gql_requests.py +288 -0
  17. calendar_event_engine-0.1.0/calendar_event_engine/publishers/mobilizon/types.py +147 -0
  18. calendar_event_engine-0.1.0/calendar_event_engine/publishers/mobilizon/uploader.py +137 -0
  19. calendar_event_engine-0.1.0/calendar_event_engine/scrapers/__init__.py +0 -0
  20. calendar_event_engine-0.1.0/calendar_event_engine/scrapers/abc_scraper.py +30 -0
  21. calendar_event_engine-0.1.0/calendar_event_engine/scrapers/google_calendar/__init__.py +0 -0
  22. calendar_event_engine-0.1.0/calendar_event_engine/scrapers/google_calendar/api.py +178 -0
  23. calendar_event_engine-0.1.0/calendar_event_engine/scrapers/google_calendar/scraper.py +73 -0
  24. calendar_event_engine-0.1.0/calendar_event_engine/scrapers/ical/__init__.py +0 -0
  25. calendar_event_engine-0.1.0/calendar_event_engine/scrapers/ical/scraper.py +111 -0
  26. calendar_event_engine-0.1.0/calendar_event_engine/scrapers/statics/__init__.py +0 -0
  27. calendar_event_engine-0.1.0/calendar_event_engine/scrapers/statics/scraper.py +75 -0
  28. calendar_event_engine-0.1.0/calendar_event_engine/types/__init__.py +0 -0
  29. calendar_event_engine-0.1.0/calendar_event_engine/types/custom_scraper.py +17 -0
  30. calendar_event_engine-0.1.0/calendar_event_engine/types/generics.py +88 -0
  31. calendar_event_engine-0.1.0/calendar_event_engine/types/submission.py +90 -0
  32. calendar_event_engine-0.1.0/calendar_event_engine/types/submission_handlers.py +21 -0
  33. calendar_event_engine-0.1.0/calendar_event_engine/utils/__init__.py +0 -0
  34. calendar_event_engine-0.1.0/calendar_event_engine/utils/location.py +34 -0
  35. calendar_event_engine-0.1.0/pyproject.toml +29 -0
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024, Ezequiel Valencia
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.1
2
+ Name: calendar_event_engine
3
+ Version: 0.1.0
4
+ Summary:
5
+ Author: Ezequiel Valencia
6
+ Requires-Python: >=3.10,<4.0
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Dist: aiohttp (>=3.10.10,<4.0.0)
12
+ Requires-Dist: beautifulsoup4 (>=4.13.4,<5.0.0)
13
+ Requires-Dist: geopy (>=2.4.1,<3.0.0)
14
+ Requires-Dist: google-api-python-client (>=2.127.0,<3.0.0)
15
+ Requires-Dist: google-auth-httplib2 (>=0.2.0,<0.3.0)
16
+ Requires-Dist: google-auth-oauthlib (>=1.2.0,<2.0.0)
17
+ Requires-Dist: gql (==3.5.0)
18
+ Requires-Dist: icalendar (>=6.0.1,<7.0.0)
19
+ Requires-Dist: pandas (>=2.2.3,<3.0.0)
20
+ Requires-Dist: pydantic (>=2.7.0,<3.0.0)
21
+ Requires-Dist: pytest (>=8.2.2,<9.0.0)
22
+ Requires-Dist: requests (>=2.31.0,<3.0.0)
23
+ Requires-Dist: requests-toolbelt (>=1.0.0,<2.0.0)
24
+ Requires-Dist: slack_sdk (>=3.32.0,<4.0.0)
25
+ Requires-Dist: tenacity (>=8.2.3,<9.0.0)
26
+ Requires-Dist: validators (>=0.34.0,<0.35.0)
27
+ Description-Content-Type: text/markdown
28
+
29
+ # EventScraper
30
+
31
+ A project dedicated to scrapping events and posting them on
32
+ different platforms for searching. Unbinding posted events from any single platform to help illuminate what is locally happening.
33
+
34
+ ## Docker (Recommended Method)
35
+ #### [Docker Image](https://hub.docker.com/repository/docker/avocadomoon/event-scraper/general)
36
+ #### Env Variables
37
+ Mobilizon Related Variables:
38
+ - MOBILIZON_ENDPOINT: Graphql endpoint for your mobilizon instance
39
+
40
+ - MOBILIZON_EMAIL: User email
41
+
42
+ - MOBILIZON_PASSWORD: User password
43
+
44
+ OS Related Variables:
45
+ - USER_ID: User process ID for running the application
46
+
47
+ - GROUP_ID: Group process ID for running the application
48
+
49
+ Misc:
50
+ - RUNNER_SUBMISSION_JSON_PATH: Remote submission file that tells where to publish and what group packages to use for sources.
51
+
52
+ - SLACK_WEBHOOK: Slack token to give notifications on how the scraping process is going and whether any maintenance is required.
53
+
54
+ ---
55
+ #### Docker Volumes
56
+ /app/config:[HOST DIR] -- The files needed within this config folder are "token.json" if you happen to use the Google Calendar scraper. Please follow instructions on the wiki page to create this "token.json" file.
57
+
@@ -0,0 +1,28 @@
1
+ # EventScraper
2
+
3
+ A project dedicated to scrapping events and posting them on
4
+ different platforms for searching. Unbinding posted events from any single platform to help illuminate what is locally happening.
5
+
6
+ ## Docker (Recommended Method)
7
+ #### [Docker Image](https://hub.docker.com/repository/docker/avocadomoon/event-scraper/general)
8
+ #### Env Variables
9
+ Mobilizon Related Variables:
10
+ - MOBILIZON_ENDPOINT: Graphql endpoint for your mobilizon instance
11
+
12
+ - MOBILIZON_EMAIL: User email
13
+
14
+ - MOBILIZON_PASSWORD: User password
15
+
16
+ OS Related Variables:
17
+ - USER_ID: User process ID for running the application
18
+
19
+ - GROUP_ID: Group process ID for running the application
20
+
21
+ Misc:
22
+ - RUNNER_SUBMISSION_JSON_PATH: Remote submission file that tells where to publish and what group packages to use for sources.
23
+
24
+ - SLACK_WEBHOOK: Slack token to give notifications on how the scraping process is going and whether any maintenance is required.
25
+
26
+ ---
27
+ #### Docker Volumes
28
+ /app/config:[HOST DIR] -- The files needed within this config folder are "token.json" if you happen to use the Google Calendar scraper. Please follow instructions on the wiki page to create this "token.json" file.
@@ -0,0 +1,169 @@
1
+ import datetime
2
+ import os
3
+ import time
4
+ import traceback
5
+ from urllib.error import HTTPError
6
+
7
+ from slack_sdk.webhook import WebhookClient
8
+
9
+ from calendar_event_engine.db_cache import SQLiteDB
10
+ from calendar_event_engine.filter import normalize_generic_event
11
+ from calendar_event_engine.logger import create_logger_from_designated_logger
12
+ from calendar_event_engine.parser.submission import get_runner_submission
13
+ from calendar_event_engine.publishers.abc_publisher import Publisher
14
+ from calendar_event_engine.scrapers.abc_scraper import Scraper
15
+ from calendar_event_engine.types.custom_scraper import CustomScraperJob
16
+ from calendar_event_engine.types.submission import GroupPackage, EventsToUploadFromCalendarID, GroupEventsKernel
17
+ from calendar_event_engine.types.submission_handlers import RunnerSubmission
18
+ from calendar_event_engine.scrapers.google_calendar.api import ExpiredToken
19
+
20
+ logger = create_logger_from_designated_logger(__name__)
21
+
22
+
23
+ def _runner(runner_submission: RunnerSubmission, custom_scrapers: dict[Publisher, list[CustomScraperJob]] = None):
24
+ continue_scraping = True
25
+ num_retries = 0
26
+ theres_an_expired_token = False
27
+ while continue_scraping and num_retries < 5:
28
+ try:
29
+ submitted_publishers: {Publisher: list[GroupPackage]} = runner_submission.publishers
30
+ for publisher in submitted_publishers.keys():
31
+ publisher: Publisher
32
+ publisher.connect()
33
+ group_package: GroupPackage
34
+ for group_package in submitted_publishers[publisher]:
35
+ logger.info(f"Reading Group Package: {group_package.package_name}")
36
+
37
+ for scraper_type in group_package.scraper_type_and_kernels.keys():
38
+ scraper: Scraper = runner_submission.respective_scrapers[scraper_type]
39
+ try:
40
+ scraper.connect_to_source()
41
+ group_event_kernels: list[GroupEventsKernel] = group_package.scraper_type_and_kernels[
42
+ scraper_type]
43
+ for event_kernel in group_event_kernels:
44
+ event_kernel: GroupEventsKernel
45
+ try:
46
+ events: list[EventsToUploadFromCalendarID] = scraper.retrieve_from_source(event_kernel)
47
+ except HTTPError as err:
48
+ if err.code == 404:
49
+ logger.warning(f"The following group is no longer available: {event_kernel.group_name}")
50
+ else:
51
+ raise
52
+ normalize_generic_event(events)
53
+ publisher.upload(events)
54
+ scraper.close_connection_to_source()
55
+ except ExpiredToken:
56
+ theres_an_expired_token = True
57
+ logger.warning("Expired token.json needs to be replaced. Will continue scraping other types")
58
+ continue
59
+ publisher.close()
60
+
61
+
62
+ if custom_scrapers:
63
+ for publisher in custom_scrapers.keys():
64
+ publisher.connect()
65
+ for scrap in custom_scrapers[publisher]:
66
+ try:
67
+ logger.info(f"Using custom scraper {scrap.scraper_name}")
68
+ scrap.custom_scraper.connect_to_source()
69
+ events = scrap.custom_scraper.retrieve_from_source(None)
70
+ normalize_generic_event(events)
71
+ publisher.upload(events)
72
+ except Exception as custom_err:
73
+ logger.error("Exception for custom scraper: " + scrap.scraper_name, custom_err)
74
+ publisher.close()
75
+ continue_scraping = False
76
+ except HTTPError as err:
77
+ if err.code == 500 and err.reason.lower() == 'Too many requests'.lower():
78
+ num_retries += 1
79
+ logger.warning("Going to sleep then retrying to scrape. Retry Num: " + num_retries)
80
+ time.sleep(120)
81
+ if theres_an_expired_token:
82
+ raise ExpiredToken
83
+
84
+ def _days_to_sleep(days):
85
+ now = datetime.datetime.now()
86
+ seconds_from_zero = (now.hour * 60 * 60)
87
+ time_at_2am = 2 * 60 * 60
88
+ time_to_sleep = time_at_2am
89
+ if seconds_from_zero > time_at_2am:
90
+ time_to_sleep = ((23 * 60 * 60) - seconds_from_zero) + time_at_2am
91
+ else:
92
+ time_to_sleep = time_at_2am - seconds_from_zero
93
+
94
+ return time_to_sleep + (60 * 60 * 24 * days)
95
+
96
+
97
+ def _produce_slack_message(color, title, text, priority):
98
+ return {
99
+ "color": color,
100
+ "author_name": "CTEvent Scraper",
101
+ "author_icon": "https://ctgrassroots.org/favicon.ico",
102
+ "title": title,
103
+ "title_link": "google.com",
104
+ "text": text,
105
+ "fields": [
106
+ {
107
+ "title": "Priority",
108
+ "value": priority,
109
+ "short": "false"
110
+ }
111
+ ],
112
+ "footer": "CTEvent Scraper",
113
+ }
114
+
115
+ def start_event_engine(remote_json_url: str, slack_webhook: WebhookClient = None, custom_scrapers: dict[Publisher, list[CustomScraperJob]] = None, test_mode: bool = False):
116
+ logger.info("Scraper Started")
117
+ sleeping = 2
118
+ while True:
119
+ #####################
120
+ # Create Submission #
121
+ #####################
122
+
123
+ cache_db: SQLiteDB = SQLiteDB(test_mode)
124
+ submission: RunnerSubmission = get_runner_submission(remote_json_url, test_mode, cache_db)
125
+
126
+ ######################
127
+ # Execute Submission #
128
+ ######################
129
+ time_to_sleep = _days_to_sleep(sleeping)
130
+ logger.info("Scraping")
131
+ try:
132
+ _runner(submission, custom_scrapers)
133
+ logger.info("Sleeping " + str(sleeping) + " Days Until Next Scrape")
134
+ except ExpiredToken:
135
+ time_to_sleep = _days_to_sleep(2)
136
+ logger.info("Sleeping " + str(sleeping) + " Days Until Next Scrape")
137
+ if slack_webhook is not None:
138
+ response = slack_webhook.send(attachments=[
139
+ _produce_slack_message("#e6e209", "Expired Token", "Replace token.json", "Medium")
140
+ ])
141
+
142
+ except Exception as e:
143
+ logger.error("Unknown Error")
144
+ logger.error(e)
145
+ logger.error(traceback.format_exc())
146
+ logger.error("Going to Sleep for 7 days")
147
+ if slack_webhook is not None:
148
+ slack_webhook.send(attachments=[
149
+ _produce_slack_message("#ab1a13", "Event Scraper Unknown Error", "Check logs for error.", "High")
150
+ ])
151
+ time_to_sleep = _days_to_sleep(7)
152
+
153
+ cache_db.close()
154
+ time.sleep(time_to_sleep)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ env_webhook = None if os.environ.get("SLACK_WEBHOOK") is None else WebhookClient(os.environ.get("SLACK_WEBHOOK"))
159
+ env_test_mode = False if "TEST_MODE" not in os.environ else True
160
+ submission_json_path = os.getenv("RUNNER_SUBMISSION_JSON_PATH")
161
+ if submission_json_path is None:
162
+ err = NotImplementedError("Did not find JSON submission path")
163
+ logger.error(err)
164
+ raise err
165
+
166
+
167
+ start_event_engine(submission_json_path, env_webhook, None, env_test_mode)
168
+
169
+
@@ -0,0 +1,147 @@
1
+ import os
2
+ import sqlite3
3
+ from datetime import datetime
4
+
5
+ from calendar_event_engine.logger import create_logger_from_designated_logger
6
+
7
+ logger = create_logger_from_designated_logger(__name__)
8
+
9
+ class UploadedEventRow:
10
+ uuid: str
11
+ id: str
12
+ title: str
13
+ date: str
14
+ groupID: str
15
+ groupName: str
16
+
17
+ def __init__(self, uuid: str, id: str, title: str, date: str, group_id: str, group_name: str):
18
+ """_summary_
19
+
20
+ Args:
21
+ uuid (str): _description_
22
+ id (str): _description_
23
+ title (str): _description_
24
+ date (str): Has to be of format ISO8601 that is YYYY-MM-DD HH:MM:SS.SSS. Can also have T in the center if desired.
25
+ group_id (str): _description_
26
+ """
27
+
28
+ self.uuid = uuid
29
+ self.id = id
30
+ self.title = title
31
+ self.date = date
32
+ self.groupID = group_id
33
+ self.groupName = group_name
34
+
35
+ class UploadSource:
36
+ uuid: str
37
+ websiteURL: str
38
+ source: str
39
+ sourceType: str
40
+
41
+ def __init__(self, uuid:str, website_url: str, source:str, source_type: str):
42
+ self.uuid = uuid
43
+ self.websiteURL = website_url
44
+ self.source = source
45
+ self.sourceType = source_type
46
+
47
+
48
+
49
+ class SQLiteDB:
50
+ sql_db_connection: sqlite3.Connection
51
+ uploaded_events_table_name = "uploaded_events"
52
+ event_source_table_name = "event_source"
53
+
54
+ allColumns = f"""{uploaded_events_table_name}.uuid, {uploaded_events_table_name}.id,
55
+ {uploaded_events_table_name}.title, {uploaded_events_table_name}.date,
56
+ {uploaded_events_table_name}.group_id, {uploaded_events_table_name}.group_name,
57
+ {event_source_table_name}.websiteURL, {event_source_table_name}.source, {event_source_table_name}.sourceType"""
58
+
59
+ def __init__(self, in_memory_sq_lite: bool = False):
60
+ if in_memory_sq_lite:
61
+ self.sql_db_connection = sqlite3.connect(":memory:")
62
+ else:
63
+ cache_db_path = os.environ.get("CACHE_DB_PATH")
64
+ if cache_db_path is not None:
65
+ self.sql_db_connection = sqlite3.connect(cache_db_path + "/event_cache.db")
66
+ else:
67
+ self.sql_db_connection = sqlite3.connect("event_cache.db")
68
+ self.initialize_db()
69
+
70
+ def initialize_db(self) -> sqlite3.Connection:
71
+ db_cursor = self.sql_db_connection.cursor()
72
+ db_cursor.execute(f"""CREATE TABLE IF NOT EXISTS {self.uploaded_events_table_name}
73
+ (uuid PRIMARY KEY, id, title text, date text, group_id, group_name)""")
74
+ db_cursor.execute(f"""CREATE TABLE IF NOT EXISTS {self.event_source_table_name}
75
+ (uuid, websiteURL text, source text, sourceType text,
76
+ FOREIGN KEY (uuid) REFERENCES uploaded_events(uuid) ON DELETE CASCADE)""" )
77
+
78
+ def close(self):
79
+ self.sql_db_connection.close()
80
+
81
+ # https://www.sqlite.org/lang_datefunc.html
82
+ # Uses built in date time function
83
+ def delete_all_month_old_events(self):
84
+ db_cursor = self.sql_db_connection.cursor()
85
+ db_cursor.execute(f"DELETE FROM {self.uploaded_events_table_name} WHERE datetime(date) < datetime('now', '-1 month')")
86
+
87
+ self.sql_db_connection.commit()
88
+
89
+ def select_all_rows_with_calendar_id(self, source):
90
+ db_cursor = self.sql_db_connection.cursor()
91
+ # Comma at the end of (groupID,) turns it into a tuple
92
+ res = db_cursor.execute(f"""SELECT {self.allColumns} FROM {self.uploaded_events_table_name}
93
+ INNER JOIN {self.event_source_table_name} ON
94
+ {self.uploaded_events_table_name}.uuid={self.event_source_table_name}.uuid
95
+ WHERE source = ?""", (source,))
96
+ return res
97
+
98
+ def get_last_event_date_for_source_id(self, calendar_id) -> datetime:
99
+ db_cursor = self.sql_db_connection.cursor()
100
+ res = db_cursor.execute(f"""SELECT date FROM {self.uploaded_events_table_name}
101
+ INNER JOIN {self.event_source_table_name} ON
102
+ {self.uploaded_events_table_name}.uuid={self.event_source_table_name}.uuid
103
+ WHERE source = ?
104
+ ORDER BY date DESC LIMIT 1""", (calendar_id,))
105
+ # Conversion to ISO format does not like the Z, that represents UTC aka no time zone
106
+ # so using +00:00 is an equivalent to it
107
+ date_string = res.fetchone()[0]
108
+ logger.debug(f"Last date found for calendar ID {calendar_id}: {date_string}")
109
+ return datetime.fromisoformat(date_string)
110
+
111
+ def no_entries_with_source_id(self, calendar_id: str) -> bool:
112
+ res = self.select_all_rows_with_calendar_id(calendar_id)
113
+ return len(res.fetchall()) == 0
114
+
115
+ def entry_already_in_cache(self, date:str, title:str, source_id:str) -> bool:
116
+ db_cursor = self.sql_db_connection.cursor()
117
+ res = db_cursor.execute(f"""SELECT {self.allColumns} FROM {self.uploaded_events_table_name}
118
+ INNER JOIN {self.event_source_table_name} ON
119
+ {self.uploaded_events_table_name}.uuid={self.event_source_table_name}.uuid
120
+ WHERE date = ? AND title = ? AND source = ?""", (date, title, source_id))
121
+ query = res.fetchall()
122
+ if len(query) > 0:
123
+ return True
124
+ return False
125
+
126
+ def insert_uploaded_event(self, row_to_add: UploadedEventRow, event_source: UploadSource):
127
+ db_cursor: sqlite3.Cursor = self.sql_db_connection.cursor()
128
+ insert_row = (row_to_add.uuid, row_to_add.id, row_to_add.title, row_to_add.date, row_to_add.groupID, row_to_add.groupName)
129
+ event_source_row= (event_source.uuid, event_source.websiteURL, event_source.source, event_source.sourceType)
130
+
131
+ db_cursor.execute(f"INSERT INTO {self.uploaded_events_table_name} VALUES (?, ?, ?, ? , ?, ?)", insert_row)
132
+ db_cursor.execute(f"INSERT INTO {self.event_source_table_name} VALUES (?, ? , ?, ?)", event_source_row)
133
+ self.sql_db_connection.commit()
134
+
135
+ def select_all_from_upload_table(self) -> sqlite3.Cursor:
136
+ db_cursor = self.sql_db_connection.cursor()
137
+ res = db_cursor.execute(f"SELECT * FROM {self.uploaded_events_table_name}")
138
+ return res
139
+
140
+ def select_all_from_event_source_table(self) -> sqlite3.Cursor:
141
+ db_cursor = self.sql_db_connection.cursor()
142
+ res = db_cursor.execute(f"SELECT * FROM {self.event_source_table_name}")
143
+ return res
144
+
145
+
146
+
147
+
@@ -0,0 +1,28 @@
1
+ import requests
2
+ import validators
3
+
4
+ from calendar_event_engine.types.generics import GenericEvent
5
+ from calendar_event_engine.types.submission import EventsToUploadFromCalendarID
6
+
7
+
8
+ # https://www.geeksforgeeks.org/http-headers-content-type/
9
+
10
+ def normalize_generic_event(events_to_upload_list: list[EventsToUploadFromCalendarID]):
11
+ allowed_image_content_type_header = ["image/gif", "image/jpeg", "image/png"]
12
+ for events_to_upload in events_to_upload_list:
13
+ generic_event: GenericEvent
14
+ for generic_event in events_to_upload.events:
15
+ # If valid URL but not a picture, set to empty string
16
+ if validators.url(generic_event.picture):
17
+ picture_head = requests.head(generic_event.picture)
18
+ if picture_head.status_code != 200 and picture_head.headers.get("Content-type") not in allowed_image_content_type_header:
19
+ generic_event.picture = ""
20
+
21
+ if not validators.url(generic_event.online_address):
22
+ generic_event.online_address = ""
23
+
24
+
25
+
26
+
27
+
28
+
@@ -0,0 +1,10 @@
1
+ import logging
2
+
3
+
4
+ def create_logger_from_designated_logger(logger_name: str, codebase_logger_level: int=logging.INFO):
5
+ logging.basicConfig(format='%(asctime)s - %(levelname)s - %(name)s - %(message)s')
6
+ logger = logging.getLogger("codebase_logger")
7
+ logger.setLevel(codebase_logger_level)
8
+ return logger.getChild(logger_name)
9
+
10
+
@@ -0,0 +1,58 @@
1
+ import json
2
+ import urllib
3
+
4
+ from calendar_event_engine.types.generics import GenericAddress, GenericEvent
5
+ from calendar_event_engine.types.submission import ScraperTypes, GroupPackage, TimeInfo, GroupEventsKernel
6
+
7
+
8
+ def none_if_not_present(x, dictionary):
9
+ return None if x not in dictionary else dictionary[x]
10
+
11
+
12
+ def retrieve_source_type(source_type_string) -> ScraperTypes:
13
+ match source_type_string:
14
+ case "STATIC":
15
+ return ScraperTypes.STATIC
16
+ case "GOOGLE_CAL":
17
+ return ScraperTypes.GOOGLE_CAL
18
+ case "ICAL":
19
+ return ScraperTypes.ICAL
20
+ case "CUSTOM":
21
+ return ScraperTypes.CUSTOM
22
+ raise TypeError("Expected a string that resolves to a scraper type, instead got: " + source_type_string)
23
+
24
+
25
+ def get_group_package(json_path: str) -> GroupPackage:
26
+ group_schema: dict = None
27
+ with urllib.request.urlopen(json_path) as f:
28
+ group_schema = json.load(f)
29
+
30
+ group_package: GroupPackage = GroupPackage({}, none_if_not_present("name", group_schema),
31
+ none_if_not_present("description", group_schema))
32
+
33
+ for group_name, group_info in group_schema["groupKernels"].items():
34
+
35
+ event_address = None if "defaultLocation" not in group_info else GenericAddress(**group_info["defaultLocation"])
36
+ event_kernel = GenericEvent(group_info["publisherInfo"], none_if_not_present("title", group_info),
37
+ none_if_not_present("beginsOn", group_info),
38
+ none_if_not_present("defaultDescription", group_info),
39
+ none_if_not_present("endsOn", group_info),
40
+ group_info["onlineAddress"], none_if_not_present("phoneAddress", group_info),
41
+ event_address)
42
+
43
+ calendar_ids = group_info["calendarIDs"]
44
+ scraper_type: ScraperTypes = retrieve_source_type(group_info["calendarType"])
45
+ time_info = None
46
+ if scraper_type == ScraperTypes.STATIC:
47
+ time_info = TimeInfo(group_info["defaultTimes"], group_info["endDate"])
48
+
49
+
50
+
51
+ if scraper_type not in group_package.scraper_type_and_kernels:
52
+ group_package.scraper_type_and_kernels[scraper_type] = []
53
+
54
+ group_package.scraper_type_and_kernels[scraper_type].append(
55
+ GroupEventsKernel(event_kernel, group_name, calendar_ids=calendar_ids,
56
+ scraper_type=scraper_type, json_source_url=json_path, time_info=time_info))
57
+
58
+ return group_package
@@ -0,0 +1,51 @@
1
+ import json
2
+ import os
3
+ import urllib
4
+
5
+ import requests
6
+
7
+ from calendar_event_engine.db_cache import SQLiteDB
8
+ from calendar_event_engine.logger import create_logger_from_designated_logger
9
+ from calendar_event_engine.parser.package import get_group_package
10
+ from calendar_event_engine.publishers.abc_publisher import Publisher
11
+ from calendar_event_engine.publishers.mobilizon.uploader import MobilizonUploader
12
+ from calendar_event_engine.scrapers.abc_scraper import Scraper
13
+ from calendar_event_engine.scrapers.google_calendar.scraper import GoogleCalendarScraper
14
+ from calendar_event_engine.scrapers.ical.scraper import ICALScraper
15
+ from calendar_event_engine.scrapers.statics.scraper import StaticScraper
16
+ from calendar_event_engine.types.submission import PublisherTypes, ScraperTypes, GroupPackage
17
+ from calendar_event_engine.types.submission_handlers import RunnerSubmission
18
+
19
+ logger = create_logger_from_designated_logger(__name__)
20
+
21
+
22
+ def get_runner_submission(remote_submission_url: str, test_mode: bool, cache_db: SQLiteDB) -> RunnerSubmission:
23
+ json_submission: dict = requests.get(remote_submission_url).json()
24
+
25
+ publisher_package: dict[Publisher, list[GroupPackage]] = dict()
26
+ respective_scrapers: dict[ScraperTypes, Scraper] = dict()
27
+ for publisher in json_submission.keys():
28
+ publisher_instance: Publisher
29
+ match publisher:
30
+ case PublisherTypes.MOBILIZON.value:
31
+ publisher_instance = MobilizonUploader(test_mode, cache_db)
32
+ case _:
33
+ raise TypeError("Expected publisher that is accepted, instead got: " + publisher)
34
+ publisher_package[publisher_instance] = []
35
+ for group_package_source_path in json_submission[publisher]:
36
+ group_package: GroupPackage = get_group_package(group_package_source_path)
37
+ publisher_package[publisher_instance].append(group_package)
38
+ for scraper_type in list(group_package.scraper_type_and_kernels.keys()):
39
+ if scraper_type not in respective_scrapers:
40
+ logger.info(f"Creating scraper of type: {scraper_type}")
41
+ match scraper_type:
42
+ case ScraperTypes.GOOGLE_CAL:
43
+ respective_scrapers[scraper_type] = GoogleCalendarScraper(cache_db)
44
+ case ScraperTypes.STATIC:
45
+ respective_scrapers[scraper_type] = StaticScraper()
46
+ case ScraperTypes.ICAL:
47
+ respective_scrapers[scraper_type] = ICALScraper(cache_db)
48
+
49
+ return RunnerSubmission(cache_db, publisher_package, test_mode, respective_scrapers)
50
+
51
+
@@ -0,0 +1,60 @@
1
+
2
+ from abc import ABC, abstractmethod
3
+
4
+ from calendar_event_engine.types.submission import EventsToUploadFromCalendarID
5
+
6
+
7
+ class Publisher(ABC):
8
+ """
9
+ The resulting website or remote destination where all scraped events will be stored.
10
+ """
11
+ @abstractmethod
12
+ def upload(self, events_to_upload: list[EventsToUploadFromCalendarID]) -> list[EventsToUploadFromCalendarID]:
13
+ """
14
+ Upload events to the publishers, and returns a list of successfully uploaded events.
15
+ :param events_to_upload:
16
+ :return:
17
+ """
18
+ pass
19
+
20
+ @abstractmethod
21
+ def update(self) -> None:
22
+ """
23
+ Update an event that has already been uploaded to the publisher.
24
+ :return:
25
+ """
26
+ pass
27
+
28
+ @abstractmethod
29
+ def connect(self) -> None:
30
+ """
31
+ Connect to the external publisher.
32
+ :return:
33
+ """
34
+ pass
35
+
36
+ @abstractmethod
37
+ def close(self) -> None:
38
+ """
39
+ Close the connection created with the publisher.
40
+ :return:
41
+ """
42
+ pass
43
+
44
+ @abstractmethod
45
+ def monitor(self) -> None:
46
+ """
47
+ Used to check already scraped events if they have changed, and update them if needed.
48
+ """
49
+ pass
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+