Skip to content

Commit 0a18161

Browse files
committed
update estela job
1 parent 87afa59 commit 0a18161

1 file changed

Lines changed: 216 additions & 0 deletions

File tree

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import requests
2+
import os
3+
import datetime
4+
from scrapy import signals
5+
6+
7+
class EstelaSlackAlerts:
8+
def __init__(self, webhook_url):
9+
self.webhook_url = webhook_url
10+
11+
@classmethod
12+
def from_crawler(cls, crawler):
13+
webhook_url = crawler.settings.get('SLACK_WEBHOOK_URL')
14+
if not webhook_url:
15+
return None
16+
17+
ext = cls(webhook_url)
18+
crawler.signals.connect(ext.spider_opened, signal=signals.spider_opened)
19+
crawler.signals.connect(ext.spider_closed, signal=signals.spider_closed)
20+
return ext
21+
22+
def spider_opened(self, spider):
23+
self.start_time = datetime.datetime.now()
24+
25+
def spider_closed(self, spider, reason):
26+
stats = spider.crawler.stats.get_stats()
27+
28+
# --- 1. Basic Metrics ---
29+
items = stats.get('item_scraped_count', 0)
30+
items_expected = getattr(spider, "ITEMS_EXPECTED", 0)
31+
responses = stats.get('downloader/response_count', 0)
32+
33+
# --- 2. Network & Error Breakdown ---
34+
status_200 = stats.get('downloader/response_status_count/200', 0)
35+
err_403 = stats.get('downloader/response_status_count/403', 0)
36+
err_407 = stats.get('downloader/response_status_count/407', 0)
37+
err_429 = stats.get('downloader/response_status_count/429', 0)
38+
err_500 = stats.get('downloader/response_status_count/500', 0)
39+
err_503 = stats.get('downloader/response_status_count/503', 0)
40+
tunnel_errors = stats.get('downloader/exception_type_count/twisted.internet.error.TimeoutError', 0)
41+
log_errors = stats.get('log_count/ERROR', 0)
42+
43+
server_errors = err_500 + err_503
44+
45+
# --- 3. Rates Calculation ---
46+
http_success_rate = (status_200 / responses * 100) if responses > 0 else 0.0
47+
goal_achievement = (items / items_expected * 100) if items_expected > 0 else None
48+
49+
# Efficiency Factor
50+
req_per_item = responses / items if items > 0 else float('inf')
51+
if req_per_item <= 3:
52+
efficiency_factor = 1.0
53+
elif req_per_item <= 4:
54+
efficiency_factor = 0.95
55+
elif req_per_item <= 5:
56+
efficiency_factor = 0.90
57+
elif req_per_item <= 7:
58+
efficiency_factor = 0.80
59+
else:
60+
efficiency_factor = 0.65
61+
62+
# Overall Success Rate
63+
if goal_achievement is not None:
64+
success_rate = ((goal_achievement * 0.7) + (http_success_rate * 0.3)) * efficiency_factor
65+
else:
66+
success_rate = http_success_rate * efficiency_factor
67+
68+
success_rate = min(100.0, max(0.0, success_rate))
69+
70+
# --- 4. Alert Triggers ---
71+
low_yield = items_expected > 0 and items < items_expected
72+
zero_items = items == 0
73+
bad_exit = reason not in ['finished', 'closespider_itemcount']
74+
high_error_rate = log_errors > (responses * 0.5) if responses > 0 else False
75+
network_issues = (err_403 + err_407 + err_429 + tunnel_errors + server_errors) > 10
76+
77+
if any([low_yield, zero_items, bad_exit, high_error_rate, network_issues]):
78+
79+
# Duration Calculation
80+
finish_time = datetime.datetime.now()
81+
if hasattr(self, 'start_time'):
82+
duration_td = finish_time - self.start_time
83+
duration = str(duration_td).split('.')[0]
84+
else:
85+
duration = "N/A"
86+
87+
# Build Anomalies List
88+
alert_reasons = []
89+
if zero_items:
90+
alert_reasons.append("• *Critical:* No items were extracted.")
91+
elif low_yield:
92+
alert_reasons.append(f"• *Low Yield:* Only {items}/{items_expected} items scraped.")
93+
if bad_exit:
94+
alert_reasons.append(f"• *Abnormal Exit:* Reason `{reason}`.")
95+
if network_issues:
96+
alert_reasons.append("• *Network Degradation:* High number of proxy bans or timeouts.")
97+
if high_error_rate and not zero_items:
98+
alert_reasons.append(f"• *High Error Rate:* {log_errors} general errors detected.")
99+
100+
anomalies_text = "\n".join(alert_reasons)
101+
goal_text = f"{round(goal_achievement, 2)}%" if goal_achievement is not None else "N/A"
102+
103+
# --- 5. Estela Environment Variables & URL Builder ---
104+
estela_spider_job = os.getenv("ESTELA_SPIDER_JOB")
105+
106+
job_id = "N/A"
107+
spider_id = "N/A"
108+
project_id = "N/A"
109+
job_url = None
110+
111+
if estela_spider_job:
112+
parts = estela_spider_job.split(".")
113+
if len(parts) == 3:
114+
job_id = parts[0]
115+
spider_id = parts[1]
116+
project_id = parts[2]
117+
else:
118+
job_id = parts[0]
119+
120+
if project_id != "N/A" and spider_id != "N/A" and job_id != "N/A":
121+
base_url = "https://hetzner-staging.bitmaker.dev"
122+
job_url = f"{base_url}/projects/{project_id}/spiders/{spider_id}/jobs/{job_id}"
123+
124+
# --- 6. Dynamic Network Fields ---
125+
network_fields = [
126+
{"type": "mrkdwn", "text": f"*200 (OK):*\n{status_200}"}
127+
]
128+
129+
if tunnel_errors > 0:
130+
network_fields.append({"type": "mrkdwn", "text": f"*Tunnel (Timeouts):*\n{tunnel_errors}"})
131+
if err_403 > 0:
132+
network_fields.append({"type": "mrkdwn", "text": f"*403 (Forbidden):*\n{err_403}"})
133+
if err_407 > 0:
134+
network_fields.append({"type": "mrkdwn", "text": f"*407 (Proxy Auth):*\n{err_407}"})
135+
if err_429 > 0:
136+
network_fields.append({"type": "mrkdwn", "text": f"*429 (Rate Limit):*\n{err_429}"})
137+
if server_errors > 0:
138+
network_fields.append({"type": "mrkdwn", "text": f"*50x (Server Errors):*\n{server_errors}"})
139+
if log_errors > 0:
140+
network_fields.append({"type": "mrkdwn", "text": f"*Log Errors:*\n{log_errors}"})
141+
142+
# Alignment Spacer
143+
if len(network_fields) % 2 != 0:
144+
network_fields.append({"type": "mrkdwn", "text": " "})
145+
146+
network_title = "🌐 *Network & Errors Breakdown*" if len(network_fields) > 2 else "🌐 *Network Traffic*"
147+
148+
# --- 7. Construct Final Slack Blocks ---
149+
blocks = [
150+
{
151+
"type": "header",
152+
"text": {"type": "plain_text", "text": "Spider Quality Alert", "emoji": True}
153+
},
154+
{
155+
"type": "section",
156+
"text": {"type": "mrkdwn", "text": f"*Spider:* `{spider.name}`\n*Anomalies Detected:*\n{anomalies_text}"}
157+
},
158+
{"type": "divider"},
159+
{
160+
"type": "section",
161+
"text": {"type": "mrkdwn", "text": "📊 *Key Performance Indicators*"},
162+
"fields": [
163+
{"type": "mrkdwn", "text": f"*Success Rate:*\n{round(success_rate, 2)}%"},
164+
{"type": "mrkdwn", "text": f"*HTTP Success:*\n{round(http_success_rate, 2)}%"},
165+
{"type": "mrkdwn", "text": f"*Goal Achieved:*\n{goal_text}"},
166+
{"type": "mrkdwn", "text": f"*Duration:*\n{duration}"}
167+
]
168+
},
169+
{"type": "divider"},
170+
{
171+
"type": "section",
172+
"text": {"type": "mrkdwn", "text": network_title},
173+
"fields": network_fields
174+
}
175+
]
176+
177+
# Add Button if Estela URL is available
178+
if job_url:
179+
blocks.append({
180+
"type": "actions",
181+
"elements": [
182+
{
183+
"type": "button",
184+
"text": {
185+
"type": "plain_text",
186+
"text": "🖥️ Ver Job en Estela",
187+
"emoji": True
188+
},
189+
"url": job_url,
190+
"style": "primary"
191+
}
192+
]
193+
})
194+
195+
# Add Footer
196+
blocks.append({
197+
"type": "context",
198+
"elements": [{"type": "mrkdwn", "text": f"Estela Job ID: {job_id} | Production Monitoring"}]
199+
})
200+
201+
payload = {
202+
"attachments": [
203+
{
204+
"color": "#D32F2F",
205+
"fallback": f"Alert: {spider.name}",
206+
"blocks": blocks
207+
}
208+
]
209+
}
210+
211+
try:
212+
requests.post(self.webhook_url, json=payload, timeout=10)
213+
except Exception as e:
214+
spider.logger.error(f"Failed to send Slack alert: {e}")
215+
else:
216+
spider.logger.info("Health Check: OK. No Slack alert triggered.")

0 commit comments

Comments
 (0)