-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathmain.py
More file actions
297 lines (238 loc) · 8.93 KB
/
Copy pathmain.py
File metadata and controls
297 lines (238 loc) · 8.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
import argparse
from dataclasses import asdict
from datetime import datetime, timedelta, timezone
import json
import logging
import os
from pathlib import Path
import pprint
import random
import sys
import traceback
import requests
from adoption_sources import SourceManual, SourceRescueGroups
from metric_collectors.bluesky import CollectorBluesky
from metric_collectors.instagram import CollectorInstagram
from metric_collectors.mastodon import CollectorMastodon
from social_posters.bluesky import PosterBluesky
from social_posters.debug import PosterDebug
from social_posters.instagram import PosterInstagram
from social_posters.mastodon import PosterMastodon
file_handler = logging.FileHandler("cutepets.log")
file_handler.setLevel(logging.DEBUG)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[file_handler, console_handler],
)
logger = logging.getLogger(__name__)
def main():
logger.info("Log started")
parser = argparse.ArgumentParser()
parser.add_argument("--debugsources", action="store_true") # this defaults to False
parser.add_argument("--debugposters", action="store_true") # this defaults to False
args = parser.parse_args()
try:
sources = create_sources(debug=args.debugsources)
posters = create_posters(debug=args.debugposters)
collectors = create_collectors(debug=args.debugposters)
run(sources, posters, collectors)
except Exception:
notify_slack_of_exception(traceback.format_exc())
raise
def create_posters(debug=False):
if debug:
return [PosterDebug()]
return [PosterMastodon(), PosterBluesky(), PosterInstagram()]
def create_collectors(debug=False):
if debug:
return []
return [CollectorBluesky(), CollectorMastodon(), CollectorInstagram()]
def create_sources(debug=False):
if debug:
cat_fixture_path = Path(__file__).parent / "tests" / "fixtures" / "sample_cats.json"
with cat_fixture_path.open() as fixture_file:
cat_animals = json.load(fixture_file)
return [
SourceManual(species="dog"),
SourceManual(species="cat", animals=cat_animals),
]
return [SourceRescueGroups()]
def run(sources, posters, collectors=None, database_path="database.json"):
pets = []
for source in sources:
try:
pets.extend(list(source.fetch_pets()))
except ValueError as exc:
raise SystemExit(str(exc)) from exc
logger.info("Fetched %d records", len(pets))
pet = pick_pet(pets, database_path=database_path)
results = []
if not pet:
logger.error("No pets available to post.")
else:
logger.info("Picked pet %s", pprint.pformat(pet))
results, published_results = publish_posts(pet, posters)
record_publish_results(pet, published_results, database_path=database_path)
collect_metrics(collectors or [], database_path=database_path)
return results
def publish_posts(pet, posters):
results = []
published_results = []
if not posters:
logger.error("No social media credentials set; skipping post.")
else:
for poster in posters:
post = poster.format_post(pet)
result = poster.publish(post)
results.append(result)
published_results.append((poster, result))
if not result.success:
logger.error(
"%s post failed: %s",
poster.platform_name,
result.error_message,
)
else:
logger.info("%s post published.", poster.platform_name)
return results, published_results
def pick_pet(pets, database_path="database.json"):
data = _read_database(database_path)
posted_pet_ids = {
posted_pet["pet_id"] for posted_pet in data.get("posted_pets", [])
}
eligible = [
pet
for pet in pets
if pet.image_url
and pet.adoption_url
and pet.pet_id not in posted_pet_ids
]
if not eligible:
raise ValueError("No eligible pet found")
return random.choice(eligible)
def record_publish_results(pet, results, database_path="database.json"):
data = _read_database(database_path)
posted_pets = data.setdefault("posted_pets", [])
posts = data.setdefault("posts", [])
posted_at = datetime.now(timezone.utc).isoformat()
posted_pets.append(
{"name": pet.name, "pet_id": pet.pet_id, "posted_at": posted_at}
)
for poster, result in results:
if not result.success:
continue
posts.append(
{
"pet_id": pet.pet_id,
"platform": poster.platform_name,
"post_id": result.post_id,
"post_url": result.post_url,
"posted_at": posted_at,
"metrics": [],
}
)
cutoff = datetime.now(timezone.utc) - timedelta(weeks=12)
data["posted_pets"] = [
item
for item in posted_pets
if datetime.fromisoformat(item["posted_at"]) >= cutoff
]
data["posts"] = [
item
for item in posts
if datetime.fromisoformat(item["posted_at"]) >= cutoff
]
_write_database(database_path, data)
def collect_metrics(collectors, database_path="database.json", window_days=14):
try:
data = _read_database(database_path)
posts = data.get("posts", [])
if not posts:
return
collectors_by_platform = {
collector.platform_name: collector for collector in collectors
}
cutoff = datetime.now(timezone.utc) - timedelta(days=window_days)
updated = False
for entry in posts:
try:
if datetime.fromisoformat(entry["posted_at"]) < cutoff:
continue
collector = collectors_by_platform.get(entry.get("platform"))
if collector is None:
continue
metrics = collector.fetch_metrics(
entry["post_id"], entry.get("post_url")
)
if metrics is None:
continue
snapshot = asdict(metrics)
snapshot["collected_at"] = datetime.now(timezone.utc).isoformat()
entry.setdefault("metrics", []).append(snapshot)
updated = True
except Exception as exc:
platform = entry.get("platform", "unknown platform")
post_id = entry.get("post_id", "unknown post")
logger.error(
"%s metric collection failed for %s: %s",
platform,
post_id,
exc,
)
if updated:
_write_database(database_path, data)
except Exception as exc:
logger.error("Metric collection failed: %s", exc)
def _read_database(database_path):
path = Path(database_path)
if not path.exists() or path.stat().st_size == 0:
return {}
try:
with path.open() as database_file:
return json.load(database_file)
except (json.JSONDecodeError, ValueError) as exc:
logger.error("%s:%s", type(exc).__name__, exc)
traceback.print_exc()
return {}
def _write_database(database_path, data):
path = Path(database_path)
temporary_path = path.with_name(f"{path.name}.tmp")
with temporary_path.open("w") as database_file:
json.dump(data, database_file, indent=4)
temporary_path.replace(path)
# Slack incoming-webhook messages have a ~40k-char limit; cap the traceback
# well below that so the post stays readable and is never rejected.
MAX_TRACEBACK_CHARS = 2500
def notify_slack_of_exception(traceback_text):
logger.info(traceback_text)
webhook_url = os.environ.get("SLACK_WEBHOOK_URL")
if not webhook_url:
logger.warning("SLACK_WEBHOOK_URL not set; skipping Slack alert.")
return
app_env = os.environ.get("APP_ENV", "local")
workflow = os.environ.get("GITHUB_WORKFLOW", "local run")
event = os.environ.get("GITHUB_EVENT_NAME")
repo = os.environ.get("GITHUB_REPOSITORY")
run_id = os.environ.get("GITHUB_RUN_ID")
run_link = (
f"https://github.com/{repo}/actions/runs/{run_id}"
if repo and run_id
else None
)
header = f"CutePetsBoston [{app_env}] run failed in *{workflow}*"
if event:
header += f" (trigger: {event})"
if run_link:
header += f" (<{run_link}|view run>)"
text = f"{header}\n```{traceback_text.strip()[-MAX_TRACEBACK_CHARS:]}```"
try:
response = requests.post(webhook_url, json={"text": text}, timeout=10)
response.raise_for_status()
except Exception as slack_exc:
logger.error("Failed to post Slack alert: %s", slack_exc)
if __name__ == "__main__":
main()