File size: 15,976 Bytes
a86f101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348b3a4
a86f101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
"""
Dataset and feature engine for ScamShield.

Feature extraction is deterministic and side-effect free. In particular, it does
not call Google Safe Browsing or any network service. Training, evaluation, and
inference all import the same functions from this module.
"""

from __future__ import annotations

import os
import re
from pathlib import Path
from typing import Iterable, Optional

import numpy as np
import pandas as pd
import tldextract
from sklearn.model_selection import train_test_split


BASE_DIR = Path(__file__).resolve().parents[1]
DATA_DIR = BASE_DIR / "data"

SUSPICIOUS_TLDS = {
    "tk", "ml", "ga", "cf", "gq", "xyz", "top", "click", "link",
    "work", "loan", "online", "site", "info", "biz", "club",
}

URL_SHORTENERS = {
    "bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly", "is.gd",
    "buff.ly", "rebrand.ly", "cutt.ly", "fkrt.it", "amzn.in",
}

LEGIT_DOMAINS = {
    "amazon.com", "amazon.in", "netflix.com", "spotify.com", "apple.com",
    "google.com", "microsoft.com", "paypal.com", "ebay.com", "walmart.com",
    "fedex.com", "ups.com", "usps.com", "shopify.com", "slack.com",
    "zoom.us", "gmail.com", "outlook.com", "yahoo.com",
    "flipkart.com", "myntra.com", "swiggy.com", "zomato.com",
    "makemytrip.com", "yatra.com", "cleartrip.com", "paytm.com",
    "phonepe.com", "hdfcbank.com", "onlinesbi.sbi", "icicibank.com",
    "axisbank.com", "kotak.com", "airindia.in", "goindigo.in",
    "irctc.co.in", "redbus.in", "olacabs.com", "hotstar.com",
    "sonyliv.com", "zee5.com", "jiocinema.com", "moneycontrol.com",
    "bseindia.com", "nseindia.com", "bookmyshow.com", "policybazaar.com",
    "nykaa.com", "ajio.com", "firstcry.com", "bigbasket.com",
    "blinkit.com", "zepto.com", "upstox.com", "groww.in", "zerodha.com",
    "airtel.in", "jio.com", "myvi.in", "vi.com", "bsnl.co.in",
    "tataplay.com", "dishtv.in", "sbicard.com", "bajajfinserv.in",
    "cred.club", "licindia.in", "epfindia.gov.in", "mseb.co.in",
    "bescom.org", "uidai.gov.in", "cibil.com",
}

TRUSTED_TLDS = {
    "gov", "gov.in", "edu", "sbi",
}

URL_FEATURE_COLS = [
    "has_url", "num_urls", "has_http", "has_https", "suspicious_tld",
    "max_url_len", "has_ip_url", "has_shortened_url", "has_legit_domain",
]

TEXT_FEATURE_COLS = [
    "num_chars", "num_words", "pct_upper", "pct_digits", "num_special",
    "urgency_count", "has_phone", "has_currency",
]

URGENCY_WORDS = {
    "urgent", "winner", "won", "free", "prize", "claim", "cash",
    "congratulations", "selected", "reward", "limited", "click",
    "password", "invoice", "crypto", "bitcoin", "wallet", "suspended",
    "blocked", "deactivated", "illegal", "arrested", "cyber", "fraud",
    "hack", "jaldi", "turant", "abhi", "kijiye", "rupaye", "paisa",
    "khata", "band", "inam", "jeeta", "loot", "kyc", "cashback",
    "lucky", "gift", "redeem", "bijli", "officer", "helpline",
    "fir", "giraftari", "arrest",
}

URGENCY_PHRASES = {
    "act now", "action required", "share your otp", "last chance",
    "court notice", "turant call", "abhi call", "aaj raat",
    "kal subah", "power cut", "connection cut", "band ho jayega",
}

CURRENCY_SYMBOLS = {"$", "\u00a3", "\u20ac", "\u20b9", "btc", "eth", "usdt"}

INDIAN_LEGIT_SMS = [
    "HDFC Bank: Rs.25,000 credited to a/c XX4521 on 02-May. Avl bal: Rs.1,42,356. -HDFC Bank",
    "Dear Customer, your ICICI a/c ending 7890 debited Rs.1,500 at AMAZON on 01-May. -ICICI Bank",
    "Your SBI a/c XXXX1234 is credited with INR 5,000.00 on 01-May-25. Bal: INR 12,450.00. -SBI",
    "Your OTP for SBI Net Banking login is 483921. Valid for 10 minutes. Do not share. -SBI",
    "PhonePe OTP: 273948 for payment of Rs.150 to Zomato. Do not share. -PhonePe",
    "Aadhaar OTP: 581234 for e-KYC verification. Valid 30 minutes. -UIDAI",
    "Airtel Thanks! Your recharge of Rs.239 is successful. Validity: 28 days. Data: 1.5GB/day. -Airtel",
    "Jio: Your recharge of Rs.299 is done. Validity 28 days, 2GB/day data. Enjoy! -Jio",
    "Your electricity bill of Rs.1,234 for account 98765 is due on 10-May. Pay via BESCOM app.",
    "Bijli Bill: Aapka MSEB bijli bill Rs.1,847 generate ho gaya hai. Due date: 10-Jun-26. Pay at mseb.co.in",
    "Your Amazon order #402-9876543 is out for delivery today. Track: amzn.in/track -Amazon",
    "Flipkart: Your order for boAt Earphones has been shipped. Tracking: fkrt.it/xyz -Flipkart",
    "Zomato: Your order from Dominos has been picked up. ETA: 25 mins. -Zomato",
    "Your IRCTC ticket PNR 4567891230 is confirmed. Train 12345 on 05-May. Seat: S4/32. -IRCTC",
    "IndiGo: Your flight 6E-456 on 05-May is confirmed. PNR: ABCDEF. Web check-in open. -IndiGo",
    "LIC: Your premium of Rs.5,000 for policy 123456789 is due on 10-May. Pay at licindia.in.",
    "EPFO: Your PF balance as of 01-May-25 is Rs.2,45,678. Check on epfindia.gov.in. -EPFO",
    "You received Rs.500 from Priya via UPI. UPI Ref: 123456789012. -Google Pay",
    "PhonePe: Rs.1,200 sent to Ajay Kumar successfully. UPI Ref: 987654321. -PhonePe",
    "Paytm: Rs.250 added to your wallet from HDFC Bank XX1234. Wallet Bal: Rs.430. -Paytm",
]

URL_RE = re.compile(r"(?:https?://|www\.)[^\s<>'\"]+", re.IGNORECASE)
BARE_DOMAIN_RE = re.compile(
    r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|org|net|edu|gov|gov\.in|co\.uk|co\.in|in|io|co|sbi|club|xyz|top|click|link|online|site|info|biz)\b",
    re.IGNORECASE,
)
PHONE_RE = re.compile(r"(?:\+91[-\s]?)?\d[\d\s-]{8,}\d")
TOKEN_RE = re.compile(r"[a-z]+|[\u0900-\u097F]+")


def get_feature_columns() -> list[str]:
    return URL_FEATURE_COLS + TEXT_FEATURE_COLS


def clean_text(text: str, remove_urls: bool = False) -> str:
    text = "" if text is None else str(text)
    if remove_urls:
        text = URL_RE.sub("", text)
    return re.sub(r"\s+", " ", text).strip()


def extract_urls(text: str) -> list[str]:
    text = "" if text is None else str(text)
    urls = URL_RE.findall(text)
    urls.extend(BARE_DOMAIN_RE.findall(text))
    cleaned = []
    for url in urls:
        cleaned.append(url.strip(".,;:!?)\"]}'").lower())
    return sorted(set(u for u in cleaned if u))


def _registered_domain(url: str) -> tuple[str, str]:
    ext = tldextract.extract(url)
    domain = ext.domain.lower()
    suffix = ext.suffix.lower()
    full_domain = f"{domain}.{suffix}".strip(".")
    return full_domain, suffix


def url_features(text: str) -> dict:
    urls = extract_urls(text)

    suspicious_tld = 0
    has_ip_url = 0
    has_shortened_url = 0
    has_legit_domain = 0
    max_url_len = 0

    for url in urls:
        max_url_len = max(max_url_len, len(url))
        full_domain, suffix = _registered_domain(url)

        if suffix in SUSPICIOUS_TLDS:
            suspicious_tld = 1
        if full_domain in URL_SHORTENERS:
            has_shortened_url = 1
        if full_domain in LEGIT_DOMAINS or suffix in TRUSTED_TLDS:
            has_legit_domain = 1
        if re.search(r"https?://\d{1,3}(?:\.\d{1,3}){3}", url, re.IGNORECASE):
            has_ip_url = 1

    return {
        "has_url": int(bool(urls)),
        "num_urls": len(urls),
        "has_http": int(any(u.startswith("http://") for u in urls)),
        "has_https": int(any(u.startswith("https://") for u in urls)),
        "suspicious_tld": suspicious_tld,
        "max_url_len": max_url_len,
        "has_ip_url": has_ip_url,
        "has_shortened_url": has_shortened_url,
        "has_legit_domain": has_legit_domain,
    }


def text_features(text: str) -> dict:
    text = "" if text is None else str(text)
    lowered = text.lower()
    tokens = TOKEN_RE.findall(lowered)

    num_chars = len(text)
    upper_count = sum(1 for c in text if c.isupper())
    digit_count = sum(1 for c in text if c.isdigit())
    special_count = sum(1 for c in text if c in "!@#$%^&*()_+-=[]{}|;:,.<>?")
    urgency_count = sum(1 for token in tokens if token in URGENCY_WORDS)
    urgency_count += sum(1 for phrase in URGENCY_PHRASES if phrase in lowered)

    return {
        "num_chars": num_chars,
        "num_words": len(tokens),
        "pct_upper": upper_count / num_chars if num_chars else 0.0,
        "pct_digits": digit_count / num_chars if num_chars else 0.0,
        "num_special": special_count,
        "urgency_count": urgency_count,
        "has_phone": int(bool(PHONE_RE.search(text))),
        "has_currency": int(any(symbol in lowered for symbol in CURRENCY_SYMBOLS)),
    }


def _standardize_label(value) -> Optional[int]:
    if value is None or (isinstance(value, float) and np.isnan(value)):
        return None
    text = str(value).strip().lower()
    mapping = {
        "spam": 1, "scam": 1, "phishing": 1, "smishing": 1, "1": 1, "true": 1,
        "ham": 0, "safe": 0, "legit": 0, "legitimate": 0, "not_spam": 0,
        "0": 0, "false": 0,
    }
    return mapping.get(text)


def _standardize_frame(df: pd.DataFrame, message_cols: Iterable[str], label_cols: Iterable[str]) -> pd.DataFrame:
    lower_map = {c.lower().strip(): c for c in df.columns}
    msg_col = next((lower_map[c] for c in message_cols if c in lower_map), None)
    lbl_col = next((lower_map[c] for c in label_cols if c in lower_map), None)

    if msg_col is None or lbl_col is None:
        return pd.DataFrame(columns=["message", "label"])

    result = pd.DataFrame({
        "message": df[msg_col].astype(str),
        "label": df[lbl_col].apply(_standardize_label),
    })
    result = result.dropna(subset=["message", "label"])
    result["label"] = result["label"].astype(int)
    result = result[result["message"].str.strip() != ""]
    return result[["message", "label"]].reset_index(drop=True)


def _load_local_spam_csv() -> pd.DataFrame:
    path = DATA_DIR / "spam.csv"
    if not path.exists():
        return pd.DataFrame(columns=["message", "label"])
    try:
        df = pd.read_csv(path, encoding="latin-1")
        result = _standardize_frame(df, ["v2", "message", "text", "sms"], ["v1", "label", "labels", "category"])
        print(f"  Local UCI spam.csv: {len(result)} messages loaded")
        return result
    except Exception as exc:
        print(f"  Warning: failed to load local spam.csv: {exc}")
        return pd.DataFrame(columns=["message", "label"])


def _load_local_parquet() -> pd.DataFrame:
    frames = []
    for path in sorted(DATA_DIR.glob("*.parquet")):
        try:
            raw = pd.read_parquet(path)
            result = _standardize_frame(raw, ["message", "text", "sms", "email"], ["label", "labels", "category", "class"])
            if len(result):
                print(f"  Local {path.name}: {len(result)} messages loaded")
                frames.append(result)
        except Exception as exc:
            print(f"  Warning: failed to load {path.name}: {exc}")
    if not frames:
        return pd.DataFrame(columns=["message", "label"])
    return pd.concat(frames, ignore_index=True)


def _load_synthetic_indian_legit() -> pd.DataFrame:
    print(f"  Synthetic Indian legit SMS: {len(INDIAN_LEGIT_SMS)} messages loaded")
    return pd.DataFrame({"message": INDIAN_LEGIT_SMS, "label": 0})


def _load_huggingface_dataset(name: str) -> pd.DataFrame:
    try:
        from datasets import load_dataset as hf_load

        ds = hf_load(name)
        frames = []
        for split_name in ds.keys():
            raw = ds[split_name].to_pandas()
            frames.append(_standardize_frame(raw, ["message", "text", "sms", "email"], ["label", "labels", "category", "class"]))
        result = pd.concat(frames, ignore_index=True)
        print(f"  HuggingFace {name}: {len(result)} messages loaded")
        return result
    except Exception as exc:
        print(f"  Warning: failed to load HuggingFace {name}: {exc}")
        return pd.DataFrame(columns=["message", "label"])


def _load_multilingual_hf(name: str) -> pd.DataFrame:
    try:
        from datasets import load_dataset as hf_load

        ds = hf_load(name)
        raw = pd.concat([ds[s].to_pandas() for s in ds.keys()], ignore_index=True)
        lower_map = {c.lower().strip(): c for c in raw.columns}
        label_col = next((lower_map[c] for c in ["label", "labels", "v1", "category", "class"] if c in lower_map), None)
        if label_col is None:
            return pd.DataFrame(columns=["message", "label"])

        message_cols = []
        for col in raw.columns:
            lower = col.lower().strip()
            if lower in {"text", "message", "sms", "v2", "text_en", "en", "text_hi", "hi", "hindi"} or lower.endswith("_hi"):
                message_cols.append(col)

        frames = []
        for col in message_cols:
            frame = pd.DataFrame({"message": raw[col].astype(str), "label": raw[label_col].apply(_standardize_label)})
            frame = frame.dropna(subset=["message", "label"])
            frame["label"] = frame["label"].astype(int)
            frame = frame[frame["message"].str.strip() != ""]
            frames.append(frame[["message", "label"]])

        result = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=["message", "label"])
        print(f"  Multilingual {name}: {len(result)} messages loaded")
        return result
    except Exception as exc:
        print(f"  Warning: failed to load multilingual {name}: {exc}")
        return pd.DataFrame(columns=["message", "label"])


def _deduplicate(df: pd.DataFrame) -> pd.DataFrame:
    df = df.copy()
    df["_norm"] = df["message"].astype(str).str.lower().str.strip().str.replace(r"\s+", " ", regex=True)
    df = df.drop_duplicates(subset="_norm", keep="first").drop(columns=["_norm"])
    return df.reset_index(drop=True)


def _add_features(df: pd.DataFrame) -> pd.DataFrame:
    url_rows = []
    text_rows = []
    cleaned = []

    for message in df["message"]:
        message = str(message)
        url_rows.append(url_features(message))
        text_rows.append(text_features(message))
        cleaned.append(clean_text(message))

    result = pd.concat(
        [df.reset_index(drop=True), pd.DataFrame(url_rows), pd.DataFrame(text_rows)],
        axis=1,
    )
    result["message"] = cleaned
    return result


def load_dataset(use_remote: Optional[bool] = None) -> pd.DataFrame:
    """
    Load SMS datasets and return message, label, and feature columns.

    Remote loaders are enabled by default. Set SCAMSHIELD_USE_REMOTE_DATA=0 or
    pass use_remote=False to train only on local files plus synthetic examples.
    """
    if use_remote is None:
        use_remote = os.getenv("SCAMSHIELD_USE_REMOTE_DATA", "1").lower() not in {"0", "false", "no"}

    print("Loading datasets...")
    frames = [
        _load_local_spam_csv(),
        _load_local_parquet(),
        _load_synthetic_indian_legit(),
    ]

    if use_remote:
        frames.extend([
            _load_huggingface_dataset("Deysi/spam-detection-dataset"),
            _load_huggingface_dataset("Ngadou/Spam_SMS"),
            _load_multilingual_hf("dbarbedillo/SMS_Spam_Multilingual_Collection_Dataset"),
        ])

    frames = [frame for frame in frames if len(frame) > 0]
    if not frames:
        raise RuntimeError("No datasets loaded. Check local data files or enable remote datasets.")

    df = pd.concat(frames, ignore_index=True)
    before = len(df)
    df = _deduplicate(df)
    if before != len(df):
        print(f"  Removed {before - len(df)} duplicate messages")

    df = _add_features(df)
    spam_count = int(df["label"].sum())
    ham_count = len(df) - spam_count
    print(f"\nDataset loaded: {len(df)} messages ({spam_count} spam, {ham_count} ham)")
    return df


def split_dataset(df: pd.DataFrame, seed: int = 42) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
    train_df, temp_df = train_test_split(
        df,
        test_size=0.30,
        stratify=df["label"],
        random_state=seed,
    )
    val_df, test_df = train_test_split(
        temp_df,
        test_size=0.50,
        stratify=temp_df["label"],
        random_state=seed,
    )
    return train_df.reset_index(drop=True), val_df.reset_index(drop=True), test_df.reset_index(drop=True)