Python দিয়ে এলোমেলো CSV পরিষ্কার: নাম, তারিখ, ফোন ও ডুপ্লিকেট

আসল data এলোমেলো। standard library দিয়ে contacts পরিষ্কার করা, প্রতিটি বাতিল row ব্যাখ্যা করা ও test-সহ একটি script বানান।

বাঁদিকে এলোমেলো CSV ও ডানদিকে পরিষ্কার CSV ফাইল

প্রতিটি data প্রজেক্ট শুরু হয় একই অনাকর্ষণীয় কাজে: পরিষ্কার করা। নামে অদ্ভুত ফাঁকা, তারিখ পাঁচ ধরনে, ফোন নম্বর দশভাবে টাইপ করা, আর একই মানুষ দুবার। এই প্রজেক্ট শুধু Python-এর standard library দিয়ে contacts CSV পরিষ্কার করার script বানায়, এবং ঠিক কোন row কেন বাতিল হলো তা জানায়। কোড ও নিচের আউটপুট প্রকাশের আগে Python 3-এ চালানো হয়েছে।

এলোমেলো ইনপুট

messy.csv:

name,email,phone,joined
  rahim   UDDIN ,Rahim@Example.com ,017-1234 5678,15/03/2025
KARIM hossain,karim@example.com,+8801812345678,2025-04-02
Nusrat jahan,nusrat@example,01912345678,"March 5, 2025"
Rahim Uddin,rahim@example.com,01712345678,16-03-2025
Sadia Islam,sadia@example.com,12345,10 Jan 2025
Tanvir Ahmed,tanvir@example.com,8801612345678,31/02/2025

এই ৬ row-এ লুকানো সমস্যা: বাড়তি ফাঁকা ও ভুল বড় হাতের অক্ষর, বড় হাতের অক্ষরের email, তিন ধরনের ফোন, 15/03/2025, 16-03-2025, March 5, 2025 ও 10 Jan 2025 ধাঁচের তারিখ, অবৈধ email, ডুপ্লিকেট মানুষ, খুব ছোট ফোন নম্বর এবং এমন তারিখ যা নেই (৩১ ফেব্রুয়ারি)।

Script

clean.py নামে সংরক্ষণ করুন:

#!/usr/bin/env python3
"""Clean a messy CSV of contacts: tidy names, dates and phone numbers, drop duplicates, report rejects."""
import argparse
import csv
import re
from datetime import datetime

DATE_FORMATS = ["%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%d %b %Y", "%B %d, %Y"]


def clean_name(value):
    return " ".join(value.split()).title()


def clean_email(value):
    email = value.strip().lower()
    return email if re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email) else None


def clean_phone(value):
    """Return a Bangladesh mobile number as +8801XXXXXXXXX, or None if it does not look valid."""
    digits = re.sub(r"\D", "", value)
    if digits.startswith("880"):
        digits = digits[3:]
    digits = digits.lstrip("0")
    return "+880" + digits if re.fullmatch(r"1[3-9]\d{8}", digits) else None


def clean_date(value):
    for fmt in DATE_FORMATS:
        try:
            return datetime.strptime(value.strip(), fmt).date().isoformat()
        except ValueError:
            continue
    return None


def clean_rows(rows):
    good, rejected, seen = [], [], set()
    for number, row in enumerate(rows, start=2):  # row 1 is the header
        cleaned = {
            "name": clean_name(row.get("name", "")),
            "email": clean_email(row.get("email", "")),
            "phone": clean_phone(row.get("phone", "")),
            "joined": clean_date(row.get("joined", "")),
        }
        problems = [key for key, value in cleaned.items() if not value]
        if problems:
            rejected.append((number, ", ".join(problems), row))
        elif cleaned["email"] in seen:
            rejected.append((number, "duplicate email", row))
        else:
            seen.add(cleaned["email"])
            good.append(cleaned)
    return good, rejected


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("source")
    parser.add_argument("output")
    args = parser.parse_args(argv)
    with open(args.source, newline="", encoding="utf-8-sig") as f:
        good, rejected = clean_rows(csv.DictReader(f))
    with open(args.output, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=["name", "email", "phone", "joined"])
        writer.writeheader()
        writer.writerows(good)
    print(f"{len(good)} rows kept, {len(rejected)} rejected")
    for number, reason, row in rejected:
        print(f"  line {number}: {reason}  {dict(row)}")


if __name__ == "__main__":
    main()

চালান

$ python3 clean.py messy.csv clean.csv
2 rows kept, 4 rejected
  line 4: email  {'name': 'Nusrat jahan', 'email': 'nusrat@example', ...}
  line 5: duplicate email  {'name': 'Rahim Uddin', 'email': 'rahim@example.com', ...}
  line 6: phone  {'name': 'Sadia Islam', 'email': 'sadia@example.com', 'phone': '12345', ...}
  line 7: joined  {'name': 'Tanvir Ahmed', ..., 'joined': '31/02/2025'}

clean.csv-এ এখন দুটি ভালো row:

name,email,phone,joined
Rahim Uddin,rahim@example.com,+8801712345678,2025-03-15
Karim Hossain,karim@example.com,+8801812345678,2025-04-02

Rahim-এর প্রথম row পরিষ্কার হয়েছে (নাম, email, ফোন ও তারিখ সবই মানসম্মত), আর দ্বিতীয় row ডুপ্লিকেট হিসেবে বাতিল, কারণ ছোট হাতের করলে email একই।

প্রতিটি cleaner কীভাবে কাজ করে

নাম। " ".join(value.split()) যেকোনো ফাঁকার সারি এক ফাঁকায় আনে, আর .title() বড় হাতের অক্ষর ঠিক করে। সতর্কতা: .title() স্থূল। এটি McDonald-কে Mcdonald করে, o'neil-কে O'Neil করে এবং অনেক আসল নাম ভুল সামলায়। আসল ব্যক্তির data-য় স্বয়ংক্রিয়ভাবে বড় হাতের অক্ষর বদলানোর আগে সাবধান হন।

Email। ছেঁটে ফেলুন, ছোট হাতের করুন, তারপর সাধারণ প্যাটার্ন: কিছু, @, কিছু, একটি বিন্দু, কিছু। এটি nusrat@example-এর মতো স্পষ্ট ভুল ধরে। মেইলবক্স যে আছে তা এটি প্রমাণ করে না। একমাত্র সত্যিকারের প্রমাণ হলো নিশ্চিতকরণ email পাঠানো।

ফোন। অঙ্ক নয় এমন সবকিছু সরান, শুরুর 880 ও শুরুর শূন্য বাদ দিন, এবং বাকিটা বাংলাদেশি মোবাইল নম্বরের মতো দেখালেই গ্রহণ করুন: 1, তারপর ৩ থেকে ৯, তারপর আরও ৮ অঙ্ক। তারপর একটি মানসম্মত রূপ +8801712345678 দিই। এটি এই অনুশীলনের সরল ফরম্যাট পরীক্ষা, পূর্ণ ফোন validator নয়, আর অপারেটরের পরিসর বদলাতে পারে। আন্তর্জাতিক data-য় Google-এর libphonenumber-ভিত্তিক library ব্যবহার করুন।

তারিখ। datetime.strptime দিয়ে ক্রমে কয়েকটি পরিচিত ফরম্যাট চেষ্টা করি। 31/02/2025 প্রতিটিতে ব্যর্থ হয় কারণ ওই দিন নেই, তাই চুপচাপ ভুল কিছুতে না বদলে বাতিল হয়। আসল বিপদ: 03/04/2025 মানে ৩ এপ্রিল বা ৪ মার্চ হতে পারে। এখানে ইচ্ছা করে শুধু দিন-আগের ফরম্যাট দিয়েছি। data-র মালিকের সঙ্গে নিয়ম ঠিক করুন; কখনো আন্দাজ করবেন না।

ডুপ্লিকেট। প্রতিটি email set-এ (দ্রুত খোঁজ) মনে রাখি এবং একই email-এর দ্বিতীয় row বাতিল করি।

সবচেয়ে গুরুত্বপূর্ণ নকশা সিদ্ধান্ত: বাতিলগুলো জানান, লুকাবেন না

Script খারাপ row ঠিক করতে কখনো মান বানায় না এবং চুপচাপ বাদও দেয় না। এটি (লাইন নম্বর, কারণ, আসল row)-এর তালিকা রাখে ও ছাপে। এতে আপনি হাতে যাচাই করতে পারেন বা যিনি ফাইল বানিয়েছেন তাকে ফেরত দিতে পারেন। নীরব পরিষ্কার বিপজ্জনক কারণ কেউ না জেনেই ভুল হারিয়ে যায়।

Test

পরিষ্কারের function ছোট ও pure, তাই আসল এলোমেলো থেকে নেওয়া উদাহরণে test করা সহজ। test_clean.py নামে সংরক্ষণ করুন:

import unittest

import clean


class CleanTest(unittest.TestCase):
    def test_phone(self):
        self.assertEqual(clean.clean_phone("017-1234 5678"), "+8801712345678")
        self.assertEqual(clean.clean_phone("+8801812345678"), "+8801812345678")
        self.assertEqual(clean.clean_phone("8801612345678"), "+8801612345678")
        self.assertIsNone(clean.clean_phone("12345"))

    def test_date_formats(self):
        self.assertEqual(clean.clean_date("15/03/2025"), "2025-03-15")
        self.assertEqual(clean.clean_date("March 5, 2025"), "2025-03-05")
        self.assertIsNone(clean.clean_date("31/02/2025"))  # not a real date

    def test_name_and_email(self):
        self.assertEqual(clean.clean_name("  rahim   UDDIN "), "Rahim Uddin")
        self.assertEqual(clean.clean_email(" A@B.com "), "a@b.com")
        self.assertIsNone(clean.clean_email("nope@x"))

    def test_duplicates_and_rejects(self):
        rows = [
            {"name": "A", "email": "a@x.com", "phone": "01712345678", "joined": "2025-01-01"},
            {"name": "A again", "email": "A@x.com", "phone": "01712345678", "joined": "2025-01-02"},
            {"name": "B", "email": "b@x.com", "phone": "bad", "joined": "2025-01-03"},
        ]
        good, rejected = clean.clean_rows(rows)
        self.assertEqual(len(good), 1)
        self.assertEqual([r[1] for r in rejected], ["duplicate email", "phone"])


if __name__ == "__main__":
    unittest.main()
$ python3 -m unittest -v
Ran 4 tests in 0.013s

OK

আসল data-য় নতুন ধরনের গোলমাল পেলে আগে test হিসেবে যোগ করুন, তারপর কোড ঠিক করুন।

Data পরিষ্কারের ভালো অভ্যাস

  • মূল ফাইল কখনো বদলাবেন না। আমরা যেমন করলাম, নতুন ফাইল লিখুন।
  • কী বদলেছে ও কী বাতিল হয়েছে তার log রাখুন।
  • আগে ও পরে গুনুন। ৬ row ঢুকল, ২ থাকল, ৪ বাতিল: হিসাব মেলে।
  • Encoding সামলান। utf-8-sig Excel-এর CSV ফাইলের শুরুতে যোগ করা অদৃশ্য চিহ্ন সামলায়।
  • আন্দাজ করবেন না। দ্ব্যর্থক data বাতিল করে জিজ্ঞেস করুন।
  • ব্যক্তিগত data-য় সাবধান। Contact তালিকা ব্যক্তিগত তথ্য। নিরাপদে রাখুন, শুধু দরকারিটুকু রাখুন এবং আপনার প্রযোজ্য আইন মানুন।

সীমা

  • এটি পুরো ফাইল মেমরিতে পড়ে, যা RAM-এ আরামে ধরে এমন ফাইলের জন্য ঠিক, কিন্তু খুব বড় ফাইলে row ধরে ধরে প্রক্রিয়া করে লিখতে লিখতে যান।
  • শুধু একটি ফোন অঞ্চল ও অল্প কয়েকটি তারিখ ফরম্যাট।
  • Fuzzy matching নেই: ভিন্ন email-সহ Rahim Uddin ও Rahim Udin ভিন্ন মানুষ গণ্য হয়।

চ্যালেঞ্জ

১. বাতিল row কারণসহ ফাইলে লেখে এমন --report rejects.csv option যোগ করুন। ২. country কলাম ও প্রতি দেশের নিয়ম যোগ করুন। ৩. non-breaking space-এর মতো অদৃশ্য অক্ষর সরান। ৪. email-এর পাশাপাশি ফোন নম্বর দিয়েও ডুপ্লিকেট ধরুন। ৫. পুরো ফাইল না এনে row ধরে ধরে stream করুন। ৬. একই পরিষ্কার pandas library দিয়ে করে কোড তুলনা করুন।

মূল কথা: পরিষ্কার হলো ছোট, পরীক্ষিত function-এর pipeline। যা পারেন মানসম্মত করুন, যা পারেন না বাতিল করুন, কোনো বাতিল row লুকাবেন না, এবং মূল ফাইল অক্ষত রাখুন।


হাতে-কলমে প্রজেক্ট সিরিজ, পর্ব ২ (পুনর্ব্যবহারযোগ্য টুল): 2 নম্বর (মোট 5)। আগের লেখা: Python ও SQLite দিয়ে URL shortener বানান (test সহ).

আরও পড়ুন