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.
- calendar_event_engine-0.1.0/LICENSE +28 -0
- calendar_event_engine-0.1.0/PKG-INFO +57 -0
- calendar_event_engine-0.1.0/README.md +28 -0
- calendar_event_engine-0.1.0/calendar_event_engine/Runner.py +169 -0
- calendar_event_engine-0.1.0/calendar_event_engine/__init__.py +0 -0
- calendar_event_engine-0.1.0/calendar_event_engine/db_cache.py +147 -0
- calendar_event_engine-0.1.0/calendar_event_engine/filter.py +28 -0
- calendar_event_engine-0.1.0/calendar_event_engine/logger.py +10 -0
- calendar_event_engine-0.1.0/calendar_event_engine/parser/__init__.py +0 -0
- calendar_event_engine-0.1.0/calendar_event_engine/parser/package.py +58 -0
- calendar_event_engine-0.1.0/calendar_event_engine/parser/submission.py +51 -0
- calendar_event_engine-0.1.0/calendar_event_engine/publishers/__init__.py +0 -0
- calendar_event_engine-0.1.0/calendar_event_engine/publishers/abc_publisher.py +60 -0
- calendar_event_engine-0.1.0/calendar_event_engine/publishers/mobilizon/__init__.py +0 -0
- calendar_event_engine-0.1.0/calendar_event_engine/publishers/mobilizon/api.py +123 -0
- calendar_event_engine-0.1.0/calendar_event_engine/publishers/mobilizon/gql_requests.py +288 -0
- calendar_event_engine-0.1.0/calendar_event_engine/publishers/mobilizon/types.py +147 -0
- calendar_event_engine-0.1.0/calendar_event_engine/publishers/mobilizon/uploader.py +137 -0
- calendar_event_engine-0.1.0/calendar_event_engine/scrapers/__init__.py +0 -0
- calendar_event_engine-0.1.0/calendar_event_engine/scrapers/abc_scraper.py +30 -0
- calendar_event_engine-0.1.0/calendar_event_engine/scrapers/google_calendar/__init__.py +0 -0
- calendar_event_engine-0.1.0/calendar_event_engine/scrapers/google_calendar/api.py +178 -0
- calendar_event_engine-0.1.0/calendar_event_engine/scrapers/google_calendar/scraper.py +73 -0
- calendar_event_engine-0.1.0/calendar_event_engine/scrapers/ical/__init__.py +0 -0
- calendar_event_engine-0.1.0/calendar_event_engine/scrapers/ical/scraper.py +111 -0
- calendar_event_engine-0.1.0/calendar_event_engine/scrapers/statics/__init__.py +0 -0
- calendar_event_engine-0.1.0/calendar_event_engine/scrapers/statics/scraper.py +75 -0
- calendar_event_engine-0.1.0/calendar_event_engine/types/__init__.py +0 -0
- calendar_event_engine-0.1.0/calendar_event_engine/types/custom_scraper.py +17 -0
- calendar_event_engine-0.1.0/calendar_event_engine/types/generics.py +88 -0
- calendar_event_engine-0.1.0/calendar_event_engine/types/submission.py +90 -0
- calendar_event_engine-0.1.0/calendar_event_engine/types/submission_handlers.py +21 -0
- calendar_event_engine-0.1.0/calendar_event_engine/utils/__init__.py +0 -0
- calendar_event_engine-0.1.0/calendar_event_engine/utils/location.py +34 -0
- 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
|
+
|
|
File without changes
|
|
@@ -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
|
+
|
|
File without changes
|
|
@@ -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
|
+
|
|
File without changes
|
|
@@ -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
|
+
|
|
File without changes
|