Sanjam19 commited on
Commit
8d89619
Β·
verified Β·
1 Parent(s): 1969131

Update ml_utils.py

Browse files
Files changed (1) hide show
  1. ml_utils.py +420 -210
ml_utils.py CHANGED
@@ -1,137 +1,232 @@
1
- # ml_utils.py improved
2
 
3
  import re
4
  import pickle
5
  import logging
 
6
  from typing import Dict, List, Tuple
7
  from urllib.parse import urlparse
8
  from pathlib import Path
9
 
 
10
  from sklearn.feature_extraction.text import TfidfVectorizer
11
  from sklearn.linear_model import LogisticRegression
 
12
  from sklearn.pipeline import Pipeline
13
- import pandas as pd
 
14
 
15
  logging.basicConfig(level=logging.INFO)
16
  logger = logging.getLogger(__name__)
17
 
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  class ScamDetectionService:
20
  def __init__(self):
21
- logger.info("Loading spam detection model...")
22
 
23
- model_path = Path("spam_model.pkl")
 
24
 
25
- if model_path.exists():
26
- with open(model_path, 'rb') as f:
27
- self.model = pickle.load(f)
28
- logger.info("Model loaded from disk.")
29
  else:
30
- logger.info("Training new model...")
31
- self.train_model()
 
 
 
 
 
 
 
 
32
 
33
  self.suspicious_shorteners = [
34
  'bit.ly', 'tinyurl.com', 'short.link', 'tiny.cc', 'ow.ly',
35
  'goo.gl', 't.co', 'rb.gy', 'is.gd', 'v.gd', 'cutt.ly'
36
  ]
37
 
38
- # Each tuple: (regex_pattern, risk_score, short_reason, detailed_reason)
39
  self.social_engineering_patterns: List[Tuple[str, float, str, str]] = [
40
  # OTP / credential harvesting
41
- (r'\bshare.{0,20}otp\b', 0.90, "OTP request", "Asks you to share an OTP β€” no legitimate org ever does this."),
42
- (r'\bconfirm.{0,20}(otp|pin|password)\b', 0.85, "Credential request", "Requests you confirm an OTP, PIN, or password β€” a classic phishing tactic."),
43
- (r'\b(last\s*4|last\s*four).{0,20}(digit|card)\b', 0.85, "Card digit request", "Asks for the last 4 digits of your card β€” used to build up to full card theft."),
44
- (r'\b(card\s*number|cvv|expiry)\b', 0.85, "Card detail request", "Requests card number, CVV, or expiry β€” your bank will never ask for these over SMS."),
45
- (r'\botp\b', 0.45, "OTP mention", "Mentions OTP β€” context suggests a transaction or verification prompt."),
46
-
 
 
 
 
47
  # Bank / govt impersonation
48
  (r'\b(bank|rbi|sbi|hdfc|icici|axis|kotak|pnb|boi).{0,30}(suspend|block|close|deactivat|restrict)\b',
49
- 0.88, "Bank account threat", "Claims your bank account is being suspended or blocked β€” banks do this via official mail, not SMS links."),
 
50
  (r'\b(fraud\s*prevention|fraud\s*team|fraud\s*department|fraud\s*monitoring)\b',
51
- 0.75, "Fraud team impersonation", "Impersonates a bank's fraud team to create panic and urgency."),
 
52
  (r'\b(kyc|know\s*your\s*customer).{0,20}(update|expire|pending|complet|verif)\b',
53
- 0.88, "KYC scam", "KYC update requests via SMS are almost always fraudulent. Use only your bank's official app."),
 
54
  (r'\baadhaar.{0,30}(link|update|verify|expire|deactivat|biometric)\b',
55
- 0.85, "Aadhaar scam", "UIDAI does not send Aadhaar deactivation or verification requests via SMS."),
 
56
  (r'\b(pan\s*card|pan).{0,30}(flag|block|verify|link|update)\b',
57
- 0.82, "PAN card scam", "PAN verification is done only through the income tax portal β€” not SMS links."),
 
58
  (r'\b(rbi|reserve\s*bank).{0,30}(notice|compliance|regulat|review|audit)\b',
59
- 0.85, "RBI impersonation", "The RBI does not contact individuals directly via SMS for compliance or audits."),
 
60
  (r'\b(income\s*tax|it\s*department|tds).{0,30}(refund|notice|verif|confirm|scrutin)\b',
61
- 0.85, "Tax dept scam", "Income tax refunds and notices come through the official e-Filing portal, not SMS."),
 
62
  (r'\b(gst|epfo|uan|pf\s*deposit).{0,30}(verif|update|link|suspend|block)\b',
63
- 0.82, "Govt portal impersonation", "Legitimate EPFO/GST communications don't ask for verification through SMS links."),
64
-
65
  # Telecom
66
  (r'\b(sim|mobile).{0,30}(deactivat|block|suspend|port).{0,20}(kyc|verif|update)\b',
67
- 0.85, "SIM KYC scam", "TRAI and telecom operators don't deactivate SIMs via SMS verification links."),
 
68
  (r'\b(airtel|jio|vi|vodafone|bsnl).{0,30}(block|suspend|deactivat|kyc|port)\b',
69
- 0.82, "Telecom impersonation", "Telecom providers handle SIM issues at stores or via their official apps β€” not SMS links."),
70
-
71
  # Digital arrest / legal threat
72
  (r'\b(cbi|cybercrime|enforcement\s*directorate|ed|police|court).{0,40}(case|notice|filed|investigation|arrest|prosecution)\b',
73
- 0.90, "Law enforcement impersonation", "CBI/ED/Police do not initiate legal proceedings via SMS. This is a 'digital arrest' scam pattern."),
 
74
  (r'\b(section\s*420|fema|pmla|ipc|money\s*laundering).{0,40}(invest|notice|case|compli)\b',
75
- 0.88, "Legal threat scam", "Citing specific legal sections over SMS to create fear is a known fraud tactic."),
76
- (r'\b(legal\s*notice|warrant|fir)\b', 0.80, "Legal threat", "Legitimate legal notices arrive through official postal or court channels, not SMS."),
77
-
 
 
78
  # Account suspension / urgency
79
  (r'\b(account|service|upi|wallet).{0,20}(suspend|block|terminat|deactivat|restrict)\b',
80
- 0.75, "Account suspension threat", "Suspension threats via SMS are a pressure tactic to make you act without thinking."),
 
81
  (r'\b(immediate|immediately|urgent|urgently).{0,30}(action|call|contact|verify|confirm)\b',
82
- 0.70, "Urgency pressure", "Manufactured urgency is the #1 social engineering tactic β€” designed to bypass rational thinking."),
83
- (r'\bwithin\s*(1|2|24|30|48|72)\s*hours?\b', 0.65, "Time pressure", "Artificial deadlines are used to pressure you into acting before you can verify the request."),
84
-
 
 
85
  # Fake helpline
86
  (r'\b(call|contact).{0,20}(helpline|support|team|officer|number).{0,30}\d{8,12}\b',
87
- 0.75, "Fake helpline", "Scammers publish fake helpline numbers β€” always call numbers from the official company website."),
88
-
89
  # Prize / lottery
90
  (r'\b(won|win|winner|winning).{0,30}(prize|lottery|lucky|reward|cash|gift|iphone|samsung)\b',
91
- 0.90, "Lottery/prize scam", "You cannot win a lottery you didn't enter. These are designed to get your personal details."),
 
92
  (r'\bcongratulations.{0,50}(won|selected|chosen|winner|shortlist)\b',
93
- 0.90, "Prize scam", "Unsolicited congratulations messages are almost universally fraudulent."),
 
94
  (r'\bclaim.{0,20}(prize|reward|cash|gift|money|amount)\b',
95
- 0.85, "Claim prize prompt", "Asking you to 'claim' a prize you weren't expecting is a classic advance fee setup."),
96
-
97
  # Job fraud
98
  (r'\b(work\s*from\s*home|earn.{0,10}per\s*day|daily\s*earning|part\s*time\s*job).{0,40}(register|fee|pay|deposit)\b',
99
- 0.85, "Job fee scam", "Legitimate jobs don't ask you to pay a registration fee upfront."),
 
100
  (r'\b(shortlisted|selected).{0,30}(job|position|role|data\s*entry).{0,30}(register|fee|limited)\b',
101
- 0.85, "Fake job shortlisting", "Unsolicited job shortlisting with urgency or a fee is a recruitment scam pattern."),
102
-
103
  # Investment fraud
104
  (r'\b(invest.{0,20}(return|profit|earning)).{0,30}(guaranteed|assured|fixed)\b',
105
- 0.88, "Investment scam", "Guaranteed returns on investments don't exist β€” this is a hallmark of financial fraud."),
 
106
  (r'\bdouble.{0,15}(money|investment|amount|profit)\b',
107
- 0.90, "Investment doubling scam", "No legitimate scheme doubles your money. This is a Ponzi/pyramid scam pattern."),
 
108
  (r'\b(crypto|trading|forex).{0,30}(group|signal|profit|return|earn).{0,30}(percent|%|lakh|crore)\b',
109
- 0.88, "Crypto trading scam", "Fake trading groups with guaranteed returns are the 'pig butchering' scam pattern."),
110
-
111
  # Phishing links
112
  (r'\bclick.{0,20}(link|here|below).{0,20}(verify|confirm|update|claim|secure)\b',
113
- 0.80, "Phishing link", "Being directed to click a link to verify or claim something is a phishing setup."),
 
114
  (r'\b(update|confirm).{0,20}(personal|bank|card|account)\s*(detail|info|data)\b',
115
- 0.85, "Data harvesting", "Requests to 'update' personal or financial details via a link are data theft attempts."),
116
-
117
  # Customs/Courier fraud
118
  (r'\b(customs|clearance).{0,30}(charge|fee|pay|pending|release)\b',
119
- 0.85, "Customs fee scam", "Customs 'clearance fees' requested via SMS are fake β€” official notices come through courier channels."),
 
120
  (r'\b(parcel|package|shipment|courier).{0,30}(stuck|hold|pending|failed).{0,30}(pay|fee|charge|verify)\b',
121
- 0.80, "Courier fraud", "Delivery failure messages asking you to pay or verify details are typically fraudulent."),
122
-
123
  # Tech support
124
  (r'\b(microsoft|apple|google).{0,30}(security|malicious|virus|malware|traffic).{0,30}(install|call|contact)\b',
125
- 0.88, "Tech support scam", "Microsoft/Apple/Google don't contact you about malware on your device via SMS."),
 
126
  ]
127
 
128
- logger.info("Model ready.")
 
 
129
 
130
- def train_model(self):
131
- """Train on SMS Spam + new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
- # Load SMS spam dataset if available
135
  try:
136
  sms_df = pd.read_csv("spam.csv", encoding='latin-1')[['v1', 'v2']]
137
  sms_df.columns = ['label_raw', 'text']
@@ -140,238 +235,357 @@ class ScamDetectionService:
140
  labels += list(sms_df['label'])
141
  logger.info(f"Loaded {len(sms_df)} SMS spam samples.")
142
  except FileNotFoundError:
143
- logger.warning("spam.csv not found, training on new dataset only.")
144
 
145
- # Load new scam dataset
146
  try:
147
- new_df = pd.read_csv("scam_detection_training_dataset.csv", encoding='latin-1')
148
  new_df.columns = [c.lower().strip() for c in new_df.columns]
149
  label_map = {
150
- 'SCAM': 1,
151
  'LOOKS_GOOD_BUT_SUSPICIOUS': 1,
152
- 'SUSPICIOUS': 1,
153
- 'FISHY_BUT_LEGITIMATE': 0,
154
- 'LEGITIMATE': 0,
155
  }
156
  new_df['label_int'] = new_df['label'].map(label_map)
157
  new_df = new_df.dropna(subset=['label_int'])
158
- # Upweight new dataset 5x (it's smaller but high quality)
159
- texts += list(new_df['message_text']) * 5
160
  labels += list(new_df['label_int'].astype(int)) * 5
161
- logger.info(f"Loaded {len(new_df)} new scam samples.")
162
  except FileNotFoundError:
163
- logger.warning("scam_detection_training_dataset.csv not found.")
164
 
165
  if not texts:
166
- raise RuntimeError("No training data found. Add spam.csv or scam_detection_training_dataset.csv.")
167
 
168
- self.model = Pipeline([
169
  ('tfidf', TfidfVectorizer(
170
  max_features=5000,
171
  ngram_range=(1, 2),
172
  stop_words='english',
173
  min_df=1,
174
- sublinear_tf=True
175
  )),
176
  ('clf', LogisticRegression(
177
  max_iter=1000,
178
  C=1.0,
179
- class_weight='balanced'
180
  ))
181
  ])
182
 
183
- logger.info(f"Training on {len(texts)} total samples...")
184
- self.model.fit(texts, labels)
185
 
186
  with open("spam_model.pkl", 'wb') as f:
187
- pickle.dump(self.model, f)
 
188
 
189
- logger.info("Model trained and saved.")
190
 
191
- def detect_language(self, text: str) -> str:
192
- if re.search(r'[\u0900-\u097F]', text):
193
- return 'hi'
194
- elif re.search(r'[\u0980-\u09FF]', text):
195
- return 'or'
196
- return 'en'
197
 
198
- def get_language_confidence(self, text: str) -> float:
199
- wc = len(text.split())
200
- return 0.6 if wc < 5 else (0.8 if wc < 15 else 0.9)
201
 
202
- def _check_social_engineering(self, text: str) -> Tuple[float, List[str], List[str]]:
203
- """
204
- Returns (max_risk_score, list_of_short_reasons, list_of_detailed_reasons).
 
 
 
 
 
205
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  text_lower = text.lower()
207
  short_reasons, detailed_reasons = [], []
208
  max_score = 0.0
209
-
210
  for pattern, score, short, detail in self.social_engineering_patterns:
211
  if re.search(pattern, text_lower):
212
  if short not in short_reasons:
213
  short_reasons.append(short)
214
  detailed_reasons.append(detail)
215
  max_score = max(max_score, score)
216
-
217
  return max_score, short_reasons, detailed_reasons
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  def analyze_text_scam(self, text: str, language: str = None) -> Dict:
220
  if not text or not text.strip():
221
  return {
222
- "risk_level": "Safe",
223
- "confidence": 0.0,
224
- "reasoning": "Empty text provided.",
225
- "user_message": "Nothing to analyze.",
226
- "detected_language": "unknown"
227
  }
228
 
229
  detected_language = language or self.detect_language(text)
230
 
231
  try:
232
- proba = self.model.predict_proba([text])[0]
233
- spam_prob = proba[1]
234
-
235
  se_score, se_short, se_detailed = self._check_social_engineering(text)
 
236
 
237
- # Final score = max of ML and rule-based (conservative)
238
- effective_score = max(spam_prob, se_score)
239
-
240
- if effective_score >= 0.55:
241
- risk_level = "Scam"
242
- confidence = effective_score
243
- elif effective_score >= 0.35:
244
- risk_level = "Suspicious"
245
- confidence = effective_score
246
  else:
247
- risk_level = "Safe"
248
- confidence = 1 - effective_score
249
 
250
- # Build reasoning string (for API consumers)
251
- reasoning_parts = [f"Spam probability: {round(spam_prob * 100, 1)}%"]
252
  if se_short:
253
- reasoning_parts.append(f"Flags: {', '.join(se_short[:3])}")
254
- reasoning = " | ".join(reasoning_parts)
255
 
256
- # Build human-readable message for UI
257
  if risk_level == "Scam":
258
  if se_detailed:
259
- detail = se_detailed[0]
260
  extra = f" Also flagged: {', '.join(se_short[1:3])}." if len(se_short) > 1 else ""
261
- user_message = f"{detail}{extra} Do not share any details or click any links."
262
  else:
263
- user_message = f"Model confidence {round(spam_prob*100)}% spam. Multiple scam signals detected. Do not share any details or click any links."
264
-
265
  elif risk_level == "Suspicious":
266
  if se_detailed:
267
- detail = se_detailed[0]
268
- flags = ", ".join(se_short[:3])
269
- user_message = f"Flagged for: {flags}. {detail} Verify independently before responding."
270
  else:
271
- user_message = f"Some characteristics of this message match spam patterns (score: {round(spam_prob*100)}%). Worth a second look before you respond."
272
-
273
- else: # Safe
274
- user_message = "No scam signals detected. That said, always be cautious β€” if something feels off, trust your gut and verify through official channels."
275
 
276
  return {
277
- "risk_level": risk_level,
278
- "confidence": round(confidence, 4),
279
- "reasoning": reasoning,
280
- "user_message": user_message,
281
- "detected_language": detected_language
282
  }
283
-
284
  except Exception as e:
285
- logger.error(f"Classification failed: {e}")
286
  return {
287
- "risk_level": "Suspicious",
288
- "confidence": 0.5,
289
- "reasoning": f"Model error: {str(e)}",
290
  "user_message": "Could not analyze this message. Treat with caution.",
291
- "detected_language": detected_language
292
  }
293
 
 
 
294
  def analyze_url_scam(self, url: str, context: str = "") -> Dict:
295
  if not url:
296
  return {
297
- "risk_level": "Safe",
298
- "confidence": 0.0,
299
- "reasoning": "No URL provided.",
300
- "user_message": "Nothing to analyze.",
301
- "domain": "",
302
- "url_status": "invalid"
303
  }
304
 
305
  try:
306
  if not url.startswith(('http://', 'https://')):
307
  url = 'http://' + url
308
 
309
- parsed_url = urlparse(url)
310
- domain = parsed_url.netloc.lower()
311
 
312
- rule_risk = 0.0
313
- rule_flags = [] # (short_label, explanation)
 
314
 
315
- # Protocol check
316
- is_http = url.startswith('http://') and not url.startswith('https://')
317
- if is_http:
318
  rule_risk += 0.30
319
- rule_flags.append(("HTTP not HTTPS", "Connection is unencrypted (HTTP). Any data you enter can be intercepted."))
 
320
 
321
- # Brand spoofing
322
  scam_domain_patterns = [
323
  "faceb00k", "paypa1", "amaz0n", "micros0ft", "g00gle",
324
  "appleid", "login-secure", "claim-your", "verify-account",
325
  "lottery", "techsupport", "quickloan", "account-update",
326
  "hdfc-", "sbi-", "icici-", "paytm-", "netflix-payment",
327
- "bluedart-track", "india-post"
328
  ]
329
- matched_brands = [p for p in scam_domain_patterns if p in domain]
330
- if matched_brands:
331
  rule_risk += 0.85
332
- rule_flags.append(("Brand spoofing", f"Domain appears to impersonate a trusted brand ({matched_brands[0]}). The real service would use its official domain."))
 
333
 
334
- # URL shorteners
335
- matched_shortener = next((s for s in self.suspicious_shorteners if s in domain), None)
336
- if matched_shortener:
337
  rule_risk += 0.55
338
- rule_flags.append(("URL shortener", f"Uses {matched_shortener} β€” hides the real destination. You have no way to know where this link actually goes."))
 
339
 
340
- # Raw IP address
341
  if re.search(r'\d+\.\d+\.\d+\.\d+', domain):
342
  rule_risk += 0.90
343
- rule_flags.append(("Raw IP address", "Uses an IP address instead of a domain name. Legitimate services never do this."))
 
344
 
345
- # Suspicious TLDs
346
- suspicious_tlds = ['.tk', '.ml', '.ga', '.cf', '.gq', '.xyz', '.top', '.pw', '.click', '.info', '.site']
347
  matched_tld = next((t for t in suspicious_tlds if domain.endswith(t)), None)
348
  if matched_tld:
349
  rule_risk += 0.65
350
- rule_flags.append(("Suspicious TLD", f"'{matched_tld}' is a free or low-cost domain commonly used in phishing campaigns."))
 
351
 
352
- # Long URL
353
  if len(url) > 100:
354
  rule_risk += 0.25
355
- rule_flags.append(("Abnormally long URL", "Very long URLs with many parameters are a common obfuscation tactic."))
 
356
 
357
- # Security keywords in URL
358
- security_words = ['login', 'verify', 'update', 'confirm', 'secure', 'account', 'signin', 'banking']
359
  matched_kw = [w for w in security_words if w in url.lower()]
360
  if matched_kw:
361
  rule_risk += 0.35
362
- rule_flags.append(("Security keywords in URL", f"Contains '{matched_kw[0]}' β€” phishing pages often use these words to appear legitimate."))
 
 
 
 
 
 
 
 
363
 
364
- # Analyze context if provided
365
- ml_risk = 0.0
366
  context_result = None
367
  if context:
368
  context_result = self.analyze_text_scam(context)
369
  if context_result['risk_level'] == 'Scam':
370
- ml_risk = context_result['confidence']
371
  elif context_result['risk_level'] == 'Suspicious':
372
- ml_risk = context_result['confidence'] * 0.6
373
-
374
- final_score = min((rule_risk * 0.65) + (ml_risk * 0.35), 1.0)
375
 
376
  if final_score >= 0.55:
377
  risk_level = "Scam"
@@ -380,55 +594,51 @@ class ScamDetectionService:
380
  else:
381
  risk_level = "Safe"
382
 
383
- # Build API reasoning
384
  flag_labels = [f[0] for f in rule_flags]
385
- reasoning = "; ".join(flag_labels) if flag_labels else "No suspicious patterns"
 
 
386
  if context_result:
387
  reasoning += f" | Message context: {context_result['risk_level']}"
388
 
389
- # Build UI message
390
  if risk_level == "Scam":
391
- if rule_flags:
392
- primary = rule_flags[0][1]
393
- extras = [f[0] for f in rule_flags[1:3]]
394
- extra_str = f" Also: {', '.join(extras)}." if extras else ""
395
- user_message = f"{primary}{extra_str} Do not open this link."
396
- else:
397
- user_message = "This URL has multiple high-risk indicators. Do not open it."
398
-
399
  elif risk_level == "Suspicious":
400
- if rule_flags:
401
- primary = rule_flags[0][1]
402
- user_message = f"{primary} Verify this URL is from an official source before clicking."
403
- else:
404
- user_message = "This URL has some unusual characteristics. Verify before clicking."
405
-
406
  else:
407
  user_message = "No major red flags detected. Still, only click links from sources you initiated contact with."
408
 
409
  return {
410
- "risk_level": risk_level,
411
- "confidence": round(final_score, 4),
412
- "reasoning": reasoning,
413
- "user_message": user_message,
414
- "domain": domain,
415
- "url_status": "analyzed"
 
 
 
 
 
416
  }
417
 
418
  except Exception as e:
 
419
  return {
420
- "risk_level": "Suspicious",
421
- "confidence": 0.5,
422
- "reasoning": f"URL analysis error: {str(e)}",
423
  "user_message": "Could not fully analyze this URL. Treat with caution.",
424
- "domain": "unknown",
425
- "url_status": "error"
426
  }
427
 
428
  def generate_user_response(self, risk_level: str) -> str:
429
  responses = {
430
- "Safe": "βœ… This message appears safe.",
431
- "Suspicious": "⚠️ Be cautious β€” this message has suspicious elements.",
432
- "Scam": "🚨 WARNING: This appears to be a scam! Do not click links or share personal info."
433
  }
434
  return responses.get(risk_level, "Unable to analyze.")
 
1
+ # ml_utils.py, adding ensemble for URL scam detection
2
 
3
  import re
4
  import pickle
5
  import logging
6
+ import numpy as np
7
  from typing import Dict, List, Tuple
8
  from urllib.parse import urlparse
9
  from pathlib import Path
10
 
11
+ import pandas as pd
12
  from sklearn.feature_extraction.text import TfidfVectorizer
13
  from sklearn.linear_model import LogisticRegression
14
+ from sklearn.ensemble import RandomForestClassifier
15
  from sklearn.pipeline import Pipeline
16
+ from sklearn.model_selection import cross_val_score
17
+ from xgboost import XGBClassifier
18
 
19
  logging.basicConfig(level=logging.INFO)
20
  logger = logging.getLogger(__name__)
21
 
22
 
23
+ # ── URL feature engineering ───────────────────────────────────────────────────
24
+
25
+ def extract_url_features(url: str) -> Dict:
26
+ """Extract structured numerical features from a URL string."""
27
+ try:
28
+ raw = url
29
+ if not url.startswith(('http://', 'https://')):
30
+ url = 'http://' + url
31
+ p = urlparse(url)
32
+ domain = p.netloc.lower()
33
+ path = p.path.lower()
34
+ full = url.lower()
35
+
36
+ SECURITY_KW = ['login', 'verify', 'update', 'confirm', 'secure',
37
+ 'account', 'kyc', 'banking', 'signin', 'validation']
38
+ BRAND_KW = ['hdfc', 'sbi', 'icici', 'paytm', 'amazon', 'google',
39
+ 'microsoft', 'paypal', 'netflix', 'airtel', 'jio',
40
+ 'phonepe', 'razorpay', 'flipkart', 'swiggy', 'zomato',
41
+ 'axis', 'kotak', 'uidai', 'npci', 'irctc']
42
+ SUSP_TLDS = ['.tk', '.ml', '.ga', '.cf', '.gq', '.xyz', '.top',
43
+ '.pw', '.click', '.site', '.co', '.in']
44
+ FREE_TLDS = ['.tk', '.ml', '.ga', '.cf', '.gq']
45
+
46
+ return {
47
+ 'length': int(len(raw)),
48
+ 'dot_count': int(raw.count('.')),
49
+ 'hyphen_count': int(raw.count('-')),
50
+ 'slash_count': int(raw.count('/')),
51
+ 'digit_count': int(sum(c.isdigit() for c in domain)),
52
+ 'is_https': int(url.startswith('https://')),
53
+ 'is_ip': int(bool(re.search(r'\d+\.\d+\.\d+\.\d+', domain))),
54
+ 'subdomain_depth': int(max(len(domain.split('.')) - 2, 0)),
55
+ 'has_security_kw': int(any(w in full for w in SECURITY_KW)),
56
+ 'has_brand_kw': int(any(w in full for w in BRAND_KW)),
57
+ 'suspicious_tld': int(any(domain.endswith(t) for t in SUSP_TLDS)),
58
+ 'has_free_tld': int(any(domain.endswith(t) for t in FREE_TLDS)),
59
+ 'path_length': int(len(p.path)),
60
+ 'has_numbers_in_domain': int(bool(re.search(r'\d', domain.split('.')[0]))),
61
+ 'hyphen_in_domain': int('-' in domain),
62
+ 'multi_hyphens': int(domain.count('-') >= 2),
63
+ 'at_sign': int('@' in url),
64
+ 'double_slash_redirect': int('//' in p.path),
65
+ 'query_length': int(len(p.query)),
66
+ 'brand_plus_hyphen': int(any(w in domain and '-' in domain for w in BRAND_KW)),
67
+ 'security_kw_in_path': int(any(w in path for w in SECURITY_KW)),
68
+ }
69
+ except Exception:
70
+ return {k: 0 for k in [
71
+ 'length', 'dot_count', 'hyphen_count', 'slash_count', 'digit_count',
72
+ 'is_https', 'is_ip', 'subdomain_depth', 'has_security_kw', 'has_brand_kw',
73
+ 'suspicious_tld', 'has_free_tld', 'path_length', 'has_numbers_in_domain',
74
+ 'hyphen_in_domain', 'multi_hyphens', 'at_sign', 'double_slash_redirect',
75
+ 'query_length', 'brand_plus_hyphen', 'security_kw_in_path',
76
+ ]}
77
+
78
+
79
  class ScamDetectionService:
80
  def __init__(self):
81
+ logger.info("Loading models...")
82
 
83
+ text_path = Path("spam_model.pkl")
84
+ url_path = Path("url_ensemble.pkl")
85
 
86
+ if text_path.exists():
87
+ with open(text_path, 'rb') as f:
88
+ self.text_model = pickle.load(f)
89
+ logger.info("Text model loaded.")
90
  else:
91
+ logger.info("Training text model...")
92
+ self.train_text_model()
93
+
94
+ if url_path.exists():
95
+ with open(url_path, 'rb') as f:
96
+ self.url_ensemble = pickle.load(f)
97
+ logger.info("URL ensemble loaded.")
98
+ else:
99
+ logger.info("Training URL ensemble...")
100
+ self.train_url_ensemble()
101
 
102
  self.suspicious_shorteners = [
103
  'bit.ly', 'tinyurl.com', 'short.link', 'tiny.cc', 'ow.ly',
104
  'goo.gl', 't.co', 'rb.gy', 'is.gd', 'v.gd', 'cutt.ly'
105
  ]
106
 
107
+ # (regex, risk_score, short_label, detailed_explanation)
108
  self.social_engineering_patterns: List[Tuple[str, float, str, str]] = [
109
  # OTP / credential harvesting
110
+ (r'\bshare.{0,20}otp\b', 0.90, "OTP request",
111
+ "Asks you to share an OTP -- no legitimate org ever does this."),
112
+ (r'\bconfirm.{0,20}(otp|pin|password)\b', 0.85, "Credential request",
113
+ "Requests you confirm an OTP, PIN, or password -- classic phishing."),
114
+ (r'\b(last\s*4|last\s*four).{0,20}(digit|card)\b', 0.85, "Card digit request",
115
+ "Asks for last 4 digits of your card -- used to build to full card theft."),
116
+ (r'\b(card\s*number|cvv|expiry)\b', 0.85, "Card detail request",
117
+ "Requests card number, CVV, or expiry -- your bank will never ask over SMS."),
118
+ (r'\botp\b', 0.45, "OTP mention",
119
+ "Mentions OTP -- context suggests a transaction or verification prompt."),
120
  # Bank / govt impersonation
121
  (r'\b(bank|rbi|sbi|hdfc|icici|axis|kotak|pnb|boi).{0,30}(suspend|block|close|deactivat|restrict)\b',
122
+ 0.88, "Bank account threat",
123
+ "Claims your bank account is being suspended -- banks use official mail, not SMS links."),
124
  (r'\b(fraud\s*prevention|fraud\s*team|fraud\s*department|fraud\s*monitoring)\b',
125
+ 0.75, "Fraud team impersonation",
126
+ "Impersonates a bank fraud team to create panic and urgency."),
127
  (r'\b(kyc|know\s*your\s*customer).{0,20}(update|expire|pending|complet|verif)\b',
128
+ 0.88, "KYC scam",
129
+ "KYC update requests via SMS are almost always fraudulent."),
130
  (r'\baadhaar.{0,30}(link|update|verify|expire|deactivat|biometric)\b',
131
+ 0.85, "Aadhaar scam",
132
+ "UIDAI does not send Aadhaar deactivation or verification requests via SMS."),
133
  (r'\b(pan\s*card|pan).{0,30}(flag|block|verify|link|update)\b',
134
+ 0.82, "PAN card scam",
135
+ "PAN verification is done only through the income tax portal -- not SMS links."),
136
  (r'\b(rbi|reserve\s*bank).{0,30}(notice|compliance|regulat|review|audit)\b',
137
+ 0.85, "RBI impersonation",
138
+ "The RBI does not contact individuals directly via SMS for compliance or audits."),
139
  (r'\b(income\s*tax|it\s*department|tds).{0,30}(refund|notice|verif|confirm|scrutin)\b',
140
+ 0.85, "Tax dept scam",
141
+ "Income tax refunds and notices come through e-Filing portal, not SMS."),
142
  (r'\b(gst|epfo|uan|pf\s*deposit).{0,30}(verif|update|link|suspend|block)\b',
143
+ 0.82, "Govt portal impersonation",
144
+ "Legitimate EPFO/GST communications don't ask for verification via SMS links."),
145
  # Telecom
146
  (r'\b(sim|mobile).{0,30}(deactivat|block|suspend|port).{0,20}(kyc|verif|update)\b',
147
+ 0.85, "SIM KYC scam",
148
+ "TRAI and telecom operators don't deactivate SIMs via SMS verification links."),
149
  (r'\b(airtel|jio|vi|vodafone|bsnl).{0,30}(block|suspend|deactivat|kyc|port)\b',
150
+ 0.82, "Telecom impersonation",
151
+ "Telecom providers handle SIM issues at stores or official apps -- not SMS links."),
152
  # Digital arrest / legal threat
153
  (r'\b(cbi|cybercrime|enforcement\s*directorate|ed|police|court).{0,40}(case|notice|filed|investigation|arrest|prosecution)\b',
154
+ 0.90, "Law enforcement impersonation",
155
+ "CBI/ED/Police do not initiate legal proceedings via SMS. This is a 'digital arrest' scam."),
156
  (r'\b(section\s*420|fema|pmla|ipc|money\s*laundering).{0,40}(invest|notice|case|compli)\b',
157
+ 0.88, "Legal threat scam",
158
+ "Citing specific legal sections over SMS to create fear is a known fraud tactic."),
159
+ (r'\b(legal\s*notice|warrant|fir)\b',
160
+ 0.80, "Legal threat",
161
+ "Legitimate legal notices arrive through official postal or court channels, not SMS."),
162
  # Account suspension / urgency
163
  (r'\b(account|service|upi|wallet).{0,20}(suspend|block|terminat|deactivat|restrict)\b',
164
+ 0.75, "Account suspension threat",
165
+ "Suspension threats via SMS are pressure tactics to make you act without thinking."),
166
  (r'\b(immediate|immediately|urgent|urgently).{0,30}(action|call|contact|verify|confirm)\b',
167
+ 0.70, "Urgency pressure",
168
+ "Manufactured urgency is the #1 social engineering tactic -- bypasses rational thinking."),
169
+ (r'\bwithin\s*(1|2|24|30|48|72)\s*hours?\b',
170
+ 0.65, "Time pressure",
171
+ "Artificial deadlines pressure you into acting before you can verify."),
172
  # Fake helpline
173
  (r'\b(call|contact).{0,20}(helpline|support|team|officer|number).{0,30}\d{8,12}\b',
174
+ 0.75, "Fake helpline",
175
+ "Scammers publish fake helpline numbers -- always call from the official website."),
176
  # Prize / lottery
177
  (r'\b(won|win|winner|winning).{0,30}(prize|lottery|lucky|reward|cash|gift|iphone|samsung)\b',
178
+ 0.90, "Lottery/prize scam",
179
+ "You cannot win a lottery you didn't enter. Designed to get your personal details."),
180
  (r'\bcongratulations.{0,50}(won|selected|chosen|winner|shortlist)\b',
181
+ 0.90, "Prize scam",
182
+ "Unsolicited congratulations messages are almost universally fraudulent."),
183
  (r'\bclaim.{0,20}(prize|reward|cash|gift|money|amount)\b',
184
+ 0.85, "Claim prize prompt",
185
+ "Asking you to 'claim' a prize you weren't expecting is a classic advance fee setup."),
186
  # Job fraud
187
  (r'\b(work\s*from\s*home|earn.{0,10}per\s*day|daily\s*earning|part\s*time\s*job).{0,40}(register|fee|pay|deposit)\b',
188
+ 0.85, "Job fee scam",
189
+ "Legitimate jobs don't ask you to pay a registration fee upfront."),
190
  (r'\b(shortlisted|selected).{0,30}(job|position|role|data\s*entry).{0,30}(register|fee|limited)\b',
191
+ 0.85, "Fake job shortlisting",
192
+ "Unsolicited job shortlisting with urgency or a fee is a recruitment scam."),
193
  # Investment fraud
194
  (r'\b(invest.{0,20}(return|profit|earning)).{0,30}(guaranteed|assured|fixed)\b',
195
+ 0.88, "Investment scam",
196
+ "Guaranteed returns don't exist -- hallmark of financial fraud."),
197
  (r'\bdouble.{0,15}(money|investment|amount|profit)\b',
198
+ 0.90, "Investment doubling scam",
199
+ "No legitimate scheme doubles your money. This is a Ponzi/pyramid scam pattern."),
200
  (r'\b(crypto|trading|forex).{0,30}(group|signal|profit|return|earn).{0,30}(percent|%|lakh|crore)\b',
201
+ 0.88, "Crypto trading scam",
202
+ "Fake trading groups with guaranteed returns -- the 'pig butchering' scam pattern."),
203
  # Phishing links
204
  (r'\bclick.{0,20}(link|here|below).{0,20}(verify|confirm|update|claim|secure)\b',
205
+ 0.80, "Phishing link",
206
+ "Being directed to click a link to verify or claim something is a phishing setup."),
207
  (r'\b(update|confirm).{0,20}(personal|bank|card|account)\s*(detail|info|data)\b',
208
+ 0.85, "Data harvesting",
209
+ "Requests to 'update' personal or financial details via a link are data theft attempts."),
210
  # Customs/Courier fraud
211
  (r'\b(customs|clearance).{0,30}(charge|fee|pay|pending|release)\b',
212
+ 0.85, "Customs fee scam",
213
+ "Customs clearance fees via SMS are fake -- official notices come through couriers."),
214
  (r'\b(parcel|package|shipment|courier).{0,30}(stuck|hold|pending|failed).{0,30}(pay|fee|charge|verify)\b',
215
+ 0.80, "Courier fraud",
216
+ "Delivery failure messages asking you to pay or verify details are typically fraudulent."),
217
  # Tech support
218
  (r'\b(microsoft|apple|google).{0,30}(security|malicious|virus|malware|traffic).{0,30}(install|call|contact)\b',
219
+ 0.88, "Tech support scam",
220
+ "Microsoft/Apple/Google don't contact you about malware via SMS."),
221
  ]
222
 
223
+ logger.info("All models ready.")
224
+
225
+ # ── Text model training ───────────────────────────────────────────────────
226
 
227
+ def train_text_model(self):
 
228
  texts, labels = [], []
229
 
 
230
  try:
231
  sms_df = pd.read_csv("spam.csv", encoding='latin-1')[['v1', 'v2']]
232
  sms_df.columns = ['label_raw', 'text']
 
235
  labels += list(sms_df['label'])
236
  logger.info(f"Loaded {len(sms_df)} SMS spam samples.")
237
  except FileNotFoundError:
238
+ logger.warning("spam.csv not found.")
239
 
 
240
  try:
241
+ new_df = pd.read_csv("scam_messages_complete_500.csv", encoding='latin-1')
242
  new_df.columns = [c.lower().strip() for c in new_df.columns]
243
  label_map = {
244
+ 'SCAM': 1,
245
  'LOOKS_GOOD_BUT_SUSPICIOUS': 1,
246
+ 'SUSPICIOUS': 1,
247
+ 'FISHY_BUT_LEGITIMATE': 0,
248
+ 'LEGITIMATE': 0,
249
  }
250
  new_df['label_int'] = new_df['label'].map(label_map)
251
  new_df = new_df.dropna(subset=['label_int'])
252
+ texts += list(new_df['message_text']) * 5
 
253
  labels += list(new_df['label_int'].astype(int)) * 5
254
+ logger.info(f"Loaded {len(new_df)} India-specific scam samples (5x upweighted).")
255
  except FileNotFoundError:
256
+ logger.warning("scam_messages_complete_500.csv not found.")
257
 
258
  if not texts:
259
+ raise RuntimeError("No training data found.")
260
 
261
+ self.text_model = Pipeline([
262
  ('tfidf', TfidfVectorizer(
263
  max_features=5000,
264
  ngram_range=(1, 2),
265
  stop_words='english',
266
  min_df=1,
267
+ sublinear_tf=True,
268
  )),
269
  ('clf', LogisticRegression(
270
  max_iter=1000,
271
  C=1.0,
272
+ class_weight='balanced',
273
  ))
274
  ])
275
 
276
+ logger.info(f"Training text model on {len(texts)} samples...")
277
+ self.text_model.fit(texts, labels)
278
 
279
  with open("spam_model.pkl", 'wb') as f:
280
+ pickle.dump(self.text_model, f)
281
+ logger.info("Text model saved -> spam_model.pkl")
282
 
283
+ # ── URL ensemble training ─────────────────────────────────────────────────
284
 
285
+ def train_url_ensemble(self):
286
+ """
287
+ Three-model soft-voting ensemble on 250-row labeled URL dataset.
 
 
 
288
 
289
+ Model A: char n-gram TF-IDF on URL string -> Logistic Regression
290
+ Learns character-level patterns (e.g. 'hdfc-', '-kyc', '.xyz').
 
291
 
292
+ Model B: 21 engineered numerical features -> Random Forest
293
+ Learns structural signals: hyphen count, TLD type, subdomain depth.
294
+
295
+ Model C: word TF-IDF on (url + red_flags + domain_pattern) -> XGBoost
296
+ Learns combined text+signal keyword interactions.
297
+
298
+ Final score: average of three probability vectors.
299
+ Override: if any single model has >= 0.85 confidence on MALICIOUS, use it.
300
  """
301
+ try:
302
+ df = pd.read_csv("scam_urls_training_250.csv", encoding='latin-1')
303
+ except FileNotFoundError:
304
+ logger.warning("scam_urls_training_250.csv not found. URL ensemble disabled.")
305
+ self.url_ensemble = None
306
+ return
307
+
308
+ label_map = {'LEGITIMATE': 0, 'SUSPICIOUS': 1, 'MALICIOUS': 2}
309
+ df['label_int'] = df['label'].map(label_map)
310
+ df = df.dropna(subset=['label_int'])
311
+ df['label_int'] = df['label_int'].astype(int)
312
+
313
+ urls = df['url'].tolist()
314
+ labels = df['label_int'].tolist()
315
+
316
+ # Model A: char n-gram TF-IDF + LR
317
+ model_a = Pipeline([
318
+ ('tfidf', TfidfVectorizer(
319
+ analyzer='char_wb',
320
+ ngram_range=(3, 5),
321
+ max_features=3000,
322
+ sublinear_tf=True,
323
+ )),
324
+ ('clf', LogisticRegression(
325
+ max_iter=1000,
326
+ C=1.0,
327
+ class_weight='balanced',
328
+ ))
329
+ ])
330
+
331
+ # Model B: engineered features + RF
332
+ feat_matrix = np.array([
333
+ list(extract_url_features(u).values()) for u in urls
334
+ ], dtype=float)
335
+
336
+ model_b = RandomForestClassifier(
337
+ n_estimators=200,
338
+ max_depth=8,
339
+ class_weight='balanced',
340
+ random_state=42,
341
+ )
342
+
343
+ # Model C: combined text + XGBoost
344
+ combined_text = [
345
+ f"{row['url']} {row.get('red_flags', '')} {row.get('domain_pattern', '')}"
346
+ for _, row in df.iterrows()
347
+ ]
348
+
349
+ tfidf_c = TfidfVectorizer(
350
+ analyzer='word',
351
+ ngram_range=(1, 2),
352
+ max_features=2000,
353
+ sublinear_tf=True,
354
+ )
355
+ feat_c = tfidf_c.fit_transform(combined_text)
356
+
357
+ model_c = XGBClassifier(
358
+ n_estimators=150,
359
+ max_depth=4,
360
+ learning_rate=0.1,
361
+ eval_metric='mlogloss',
362
+ random_state=42,
363
+ verbosity=0,
364
+ )
365
+
366
+ logger.info("Training Model A (char TF-IDF + LR)...")
367
+ model_a.fit(urls, labels)
368
+ cv_a = cross_val_score(model_a, urls, labels, cv=3, scoring='balanced_accuracy').mean()
369
+ logger.info(f" Model A 3-fold balanced accuracy: {cv_a:.3f}")
370
+
371
+ logger.info("Training Model B (engineered features + RF)...")
372
+ model_b.fit(feat_matrix, labels)
373
+ cv_b = cross_val_score(model_b, feat_matrix, labels, cv=3, scoring='balanced_accuracy').mean()
374
+ logger.info(f" Model B 3-fold balanced accuracy: {cv_b:.3f}")
375
+
376
+ logger.info("Training Model C (combined text + XGBoost)...")
377
+ model_c.fit(feat_c, labels)
378
+ cv_c = cross_val_score(model_c, feat_c, labels, cv=3, scoring='balanced_accuracy').mean()
379
+ logger.info(f" Model C 3-fold balanced accuracy: {cv_c:.3f}")
380
+
381
+ self.url_ensemble = {
382
+ 'model_a': model_a,
383
+ 'model_b': model_b,
384
+ 'model_b_feat_names': list(extract_url_features(urls[0]).keys()),
385
+ 'model_c_tfidf': tfidf_c,
386
+ 'model_c': model_c,
387
+ 'label_map_inv': {0: 'LEGITIMATE', 1: 'SUSPICIOUS', 2: 'MALICIOUS'},
388
+ 'cv_scores': {'model_a': cv_a, 'model_b': cv_b, 'model_c': cv_c},
389
+ }
390
+
391
+ with open("url_ensemble.pkl", 'wb') as f:
392
+ pickle.dump(self.url_ensemble, f)
393
+ logger.info(
394
+ f"URL ensemble saved -> url_ensemble.pkl "
395
+ f"(A={cv_a:.3f}, B={cv_b:.3f}, C={cv_c:.3f})"
396
+ )
397
+
398
+ # ── Helpers ───────────────────────────────────────────────────────────────
399
+
400
+ def _check_social_engineering(self, text: str) -> Tuple[float, List[str], List[str]]:
401
  text_lower = text.lower()
402
  short_reasons, detailed_reasons = [], []
403
  max_score = 0.0
 
404
  for pattern, score, short, detail in self.social_engineering_patterns:
405
  if re.search(pattern, text_lower):
406
  if short not in short_reasons:
407
  short_reasons.append(short)
408
  detailed_reasons.append(detail)
409
  max_score = max(max_score, score)
 
410
  return max_score, short_reasons, detailed_reasons
411
 
412
+ def detect_language(self, text: str) -> str:
413
+ if re.search(r'[\u0900-\u097F]', text):
414
+ return 'hi'
415
+ elif re.search(r'[\u0980-\u09FF]', text):
416
+ return 'or'
417
+ return 'en'
418
+
419
+ def _ensemble_url_predict(self, url: str, red_flags_hint: str = "") -> Tuple[float, float, float]:
420
+ """Returns (p_legitimate, p_suspicious, p_malicious). Falls back to safe if ensemble missing."""
421
+ if not self.url_ensemble:
422
+ return 1.0, 0.0, 0.0
423
+
424
+ e = self.url_ensemble
425
+ combined = f"{url} {red_flags_hint}"
426
+
427
+ pa = e['model_a'].predict_proba([url])[0]
428
+
429
+ feats = np.array([list(extract_url_features(url).values())], dtype=float)
430
+ pb = e['model_b'].predict_proba(feats)[0]
431
+
432
+ feat_c = e['model_c_tfidf'].transform([combined])
433
+ pc = e['model_c'].predict_proba(feat_c)[0]
434
+
435
+ avg = (pa + pb + pc) / 3.0
436
+
437
+ # Single-model override if very confident on MALICIOUS
438
+ for probs in [pa, pb, pc]:
439
+ if probs[2] >= 0.85:
440
+ avg = probs
441
+ break
442
+
443
+ return float(avg[0]), float(avg[1]), float(avg[2])
444
+
445
+ # ── Text analysis ─────────────────────────────────────────────────────────
446
+
447
  def analyze_text_scam(self, text: str, language: str = None) -> Dict:
448
  if not text or not text.strip():
449
  return {
450
+ "risk_level": "Safe", "confidence": 0.0,
451
+ "reasoning": "Empty text.", "user_message": "Nothing to analyze.",
452
+ "detected_language": "unknown",
 
 
453
  }
454
 
455
  detected_language = language or self.detect_language(text)
456
 
457
  try:
458
+ proba = self.text_model.predict_proba([text])[0]
459
+ spam_prob = proba[1]
 
460
  se_score, se_short, se_detailed = self._check_social_engineering(text)
461
+ effective = max(spam_prob, se_score)
462
 
463
+ if effective >= 0.55:
464
+ risk_level, confidence = "Scam", effective
465
+ elif effective >= 0.35:
466
+ risk_level, confidence = "Suspicious", effective
 
 
 
 
 
467
  else:
468
+ risk_level, confidence = "Safe", 1 - effective
 
469
 
470
+ reasoning = f"Spam probability: {round(spam_prob * 100, 1)}%"
 
471
  if se_short:
472
+ reasoning += f" | Flags: {', '.join(se_short[:3])}"
 
473
 
 
474
  if risk_level == "Scam":
475
  if se_detailed:
 
476
  extra = f" Also flagged: {', '.join(se_short[1:3])}." if len(se_short) > 1 else ""
477
+ user_message = f"{se_detailed[0]}{extra} Do not share any details or click any links."
478
  else:
479
+ user_message = f"Model confidence {round(spam_prob*100)}%. Multiple scam signals detected. Do not share details or click links."
 
480
  elif risk_level == "Suspicious":
481
  if se_detailed:
482
+ user_message = f"Flagged for: {', '.join(se_short[:3])}. {se_detailed[0]} Verify independently before responding."
 
 
483
  else:
484
+ user_message = f"Some characteristics match spam patterns (score: {round(spam_prob*100)}%). Worth a second look."
485
+ else:
486
+ user_message = "No scam signals detected. Still be cautious -- if something feels off, verify through official channels."
 
487
 
488
  return {
489
+ "risk_level": risk_level,
490
+ "confidence": round(confidence, 4),
491
+ "reasoning": reasoning,
492
+ "user_message": user_message,
493
+ "detected_language": detected_language,
494
  }
 
495
  except Exception as e:
496
+ logger.error(f"Text classification failed: {e}")
497
  return {
498
+ "risk_level": "Suspicious", "confidence": 0.5,
499
+ "reasoning": f"Model error: {e}",
 
500
  "user_message": "Could not analyze this message. Treat with caution.",
501
+ "detected_language": detected_language,
502
  }
503
 
504
+ # ── URL analysis (ensemble + rules blended) ───────────────────────────────
505
+
506
  def analyze_url_scam(self, url: str, context: str = "") -> Dict:
507
  if not url:
508
  return {
509
+ "risk_level": "Safe", "confidence": 0.0,
510
+ "reasoning": "No URL provided.", "user_message": "Nothing to analyze.",
511
+ "domain": "", "url_status": "invalid",
 
 
 
512
  }
513
 
514
  try:
515
  if not url.startswith(('http://', 'https://')):
516
  url = 'http://' + url
517
 
518
+ domain = urlparse(url).netloc.lower()
 
519
 
520
+ # Rule layer
521
+ rule_risk = 0.0
522
+ rule_flags = []
523
 
524
+ if url.startswith('http://') and not url.startswith('https://'):
 
 
525
  rule_risk += 0.30
526
+ rule_flags.append(("HTTP not HTTPS",
527
+ "Connection is unencrypted. Any data you enter can be intercepted."))
528
 
 
529
  scam_domain_patterns = [
530
  "faceb00k", "paypa1", "amaz0n", "micros0ft", "g00gle",
531
  "appleid", "login-secure", "claim-your", "verify-account",
532
  "lottery", "techsupport", "quickloan", "account-update",
533
  "hdfc-", "sbi-", "icici-", "paytm-", "netflix-payment",
534
+ "bluedart-track", "india-post",
535
  ]
536
+ matched = [p for p in scam_domain_patterns if p in domain]
537
+ if matched:
538
  rule_risk += 0.85
539
+ rule_flags.append(("Brand spoofing",
540
+ f"Domain impersonates a trusted brand ({matched[0]}). Use the official domain."))
541
 
542
+ shortener = next((s for s in self.suspicious_shorteners if s in domain), None)
543
+ if shortener:
 
544
  rule_risk += 0.55
545
+ rule_flags.append(("URL shortener",
546
+ f"Uses {shortener} -- hides the real destination."))
547
 
 
548
  if re.search(r'\d+\.\d+\.\d+\.\d+', domain):
549
  rule_risk += 0.90
550
+ rule_flags.append(("Raw IP address",
551
+ "Legitimate services never use raw IP addresses."))
552
 
553
+ suspicious_tlds = ['.tk', '.ml', '.ga', '.cf', '.gq', '.xyz',
554
+ '.top', '.pw', '.click', '.info', '.site']
555
  matched_tld = next((t for t in suspicious_tlds if domain.endswith(t)), None)
556
  if matched_tld:
557
  rule_risk += 0.65
558
+ rule_flags.append(("Suspicious TLD",
559
+ f"'{matched_tld}' is commonly used in phishing campaigns."))
560
 
 
561
  if len(url) > 100:
562
  rule_risk += 0.25
563
+ rule_flags.append(("Abnormally long URL",
564
+ "Very long URLs with many parameters are a common obfuscation tactic."))
565
 
566
+ security_words = ['login', 'verify', 'update', 'confirm',
567
+ 'secure', 'account', 'signin', 'banking']
568
  matched_kw = [w for w in security_words if w in url.lower()]
569
  if matched_kw:
570
  rule_risk += 0.35
571
+ rule_flags.append(("Security keywords in URL",
572
+ f"Contains '{matched_kw[0]}' -- phishing pages use these to appear legitimate."))
573
+
574
+ # Ensemble prediction
575
+ p_legit, p_susp, p_mal = self._ensemble_url_predict(url)
576
+ ensemble_risk = p_mal * 1.0 + p_susp * 0.5
577
+
578
+ # 50/50 blend
579
+ final_score = min(0.50 * min(rule_risk, 1.0) + 0.50 * ensemble_risk, 1.0)
580
 
581
+ # Context boost from message analysis
 
582
  context_result = None
583
  if context:
584
  context_result = self.analyze_text_scam(context)
585
  if context_result['risk_level'] == 'Scam':
586
+ final_score = min(final_score + 0.15, 1.0)
587
  elif context_result['risk_level'] == 'Suspicious':
588
+ final_score = min(final_score + 0.07, 1.0)
 
 
589
 
590
  if final_score >= 0.55:
591
  risk_level = "Scam"
 
594
  else:
595
  risk_level = "Safe"
596
 
 
597
  flag_labels = [f[0] for f in rule_flags]
598
+ reasoning = "; ".join(flag_labels) if flag_labels else "No rule flags"
599
+ reasoning += (f" | Ensemble: {round(p_mal*100)}% malicious, "
600
+ f"{round(p_susp*100)}% suspicious")
601
  if context_result:
602
  reasoning += f" | Message context: {context_result['risk_level']}"
603
 
 
604
  if risk_level == "Scam":
605
+ primary = rule_flags[0][1] if rule_flags else "High-risk URL detected by ensemble classifier."
606
+ extras = [f[0] for f in rule_flags[1:3]]
607
+ extra_s = f" Also: {', '.join(extras)}." if extras else ""
608
+ user_message = f"{primary}{extra_s} Do not open this link."
 
 
 
 
609
  elif risk_level == "Suspicious":
610
+ primary = rule_flags[0][1] if rule_flags else "URL has unusual structural characteristics."
611
+ user_message = f"{primary} Verify this is from an official source before clicking."
 
 
 
 
612
  else:
613
  user_message = "No major red flags detected. Still, only click links from sources you initiated contact with."
614
 
615
  return {
616
+ "risk_level": risk_level,
617
+ "confidence": round(final_score, 4),
618
+ "reasoning": reasoning,
619
+ "user_message": user_message,
620
+ "domain": domain,
621
+ "url_status": "analyzed",
622
+ "ensemble_scores": {
623
+ "p_legitimate": round(p_legit, 3),
624
+ "p_suspicious": round(p_susp, 3),
625
+ "p_malicious": round(p_mal, 3),
626
+ },
627
  }
628
 
629
  except Exception as e:
630
+ logger.error(f"URL analysis error: {e}")
631
  return {
632
+ "risk_level": "Suspicious", "confidence": 0.5,
633
+ "reasoning": f"URL analysis error: {e}",
 
634
  "user_message": "Could not fully analyze this URL. Treat with caution.",
635
+ "domain": "unknown", "url_status": "error",
 
636
  }
637
 
638
  def generate_user_response(self, risk_level: str) -> str:
639
  responses = {
640
+ "Safe": "This message appears safe.",
641
+ "Suspicious": "Be cautious -- this message has suspicious elements.",
642
+ "Scam": "WARNING: This appears to be a scam! Do not click links or share personal info.",
643
  }
644
  return responses.get(risk_level, "Unable to analyze.")