import os import re import json import logging import nltk from Sastrawi.Stemmer.StemmerFactory import StemmerFactory # Setup logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # Ensure NLTK data is downloaded try: nltk.data.find('tokenizers/punkt') except LookupError: logger.info("Downloading NLTK punkt tokenizer...") nltk.download('punkt', quiet=True) try: nltk.data.find('tokenizers/punkt_tab') except LookupError: logger.info("Downloading NLTK punkt_tab...") nltk.download('punkt_tab', quiet=True) try: nltk.data.find('corpora/stopwords') except LookupError: logger.info("Downloading NLTK stopwords...") nltk.download('stopwords', quiet=True) from nltk.tokenize import word_tokenize from nltk.corpus import stopwords as nltk_stopwords # Initialize Sastrawi Stemmer logger.info("Initializing Sastrawi Stemmer...") stemmer_factory = StemmerFactory() sastrawi_stemmer = stemmer_factory.create_stemmer() # Caching for Sastrawi Stemmer to speed up processing CACHE_PATH = "data/stem_cache.json" STEM_CACHE = {} def load_stem_cache(): global STEM_CACHE if os.path.exists(CACHE_PATH): try: with open(CACHE_PATH, 'r', encoding='utf-8') as f: STEM_CACHE = json.load(f) logger.info(f"Loaded {len(STEM_CACHE)} stemmed words from cache file.") except Exception as e: logger.warning(f"Failed to load stem cache: {e}") def save_stem_cache(): if STEM_CACHE: try: os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True) with open(CACHE_PATH, 'w', encoding='utf-8') as f: json.dump(STEM_CACHE, f, ensure_ascii=False, indent=4) logger.info(f"Saved {len(STEM_CACHE)} stemmed words to cache file.") except Exception as e: logger.warning(f"Failed to save stem cache: {e}") def stem_word(word: str) -> str: """ Stems a single word using Sastrawi with a global cache. """ if word in STEM_CACHE: return STEM_CACHE[word] stemmed = sastrawi_stemmer.stem(word) STEM_CACHE[word] = stemmed return stemmed # Build unified stopwords set try: indonesian_stop = set(nltk_stopwords.words('indonesian')) except Exception: logger.warning("Failed to load NLTK indonesian stopwords, using empty fallback.") indonesian_stop = set() try: english_stop = set(nltk_stopwords.words('english')) except Exception: logger.warning("Failed to load NLTK english stopwords, using empty fallback.") english_stop = set() custom_stops = { "yang", "di", "dan", "ke", "dari", "untuk", "dengan", "ini", "itu", "atau", "adalah", "pada", "juga", "saya", "kami", "mereka", "dia", "anda", "telah", "dalam", "oleh", "serta", "karena", "tersebut", "bisa", "ada", "lebih", "bagi", "akan", "dapat", "ia", "sebagai", "bahwa", "tidak", "hanya", "seperti", "yaitu", "para", "namun", "terkait", "mengatakan", "menjadi", "juga", "tentang", "hanya", "dari", "ia", "oleh", "hingga", "dalam", "setelah", "namun", "sebuah", "salah", "satu", "ia", "telah", "ia", "serta", "atau", "ada", "bisa", "olehnya", "selain", "ia", "bahwasanya", "jika", "maka", "apabila", "saat", "saatnya", "begitu", "sehingga", "serta", "berbagai", "secara", "terhadap", "melalui", "maupun", "lalu", "kemudian", "kepada", "kembali", "banyak", "beberapa", "hal", "tersebut", "ia", "kita", "kamu", "dia", "mereka", "anda", "kalian", "mereka" } ALL_STOPWORDS = indonesian_stop.union(english_stop).union(custom_stops) def clean_text_full(text: str) -> str: """ Cleans raw text by removing HTML tags, URLs, email addresses, digits, special characters, and emojis. Returns cleaned lowercase text. """ if not text: return "" # 1. Remove HTML tags text = re.sub(r'<[^>]+>', '', text) # 2. Remove URLs text = re.sub(r'https?://\S+|www\.\S+', '', text) # 3. Remove email addresses text = re.sub(r'\S+@\S+', '', text) # 4. Remove digits and numbers text = re.sub(r'\d+', '', text) # 5. Remove special characters/punctuation/emojis (keep letters and spaces) text = re.sub(r'[^a-zA-Z\s\-]', ' ', text) # 6. Normalize multiple spaces/newlines to a single space text = re.sub(r'\s+', ' ', text).strip() # 7. Convert to lowercase return text.lower() def preprocess_article(text: str) -> tuple: """ Preprocesses raw article text: cleans, tokenizes, removes stopwords, and stems words. Returns a tuple of (clean_text, clean_tokens). """ # Step 1: Clean text cleaned_text = clean_text_full(text) # Step 2: Tokenize tokens = word_tokenize(cleaned_text) # Step 3 & 4: Stopword removal and Stemming clean_tokens = [] for token in tokens: # Resolve hyphenated words (e.g., 'toko-toko' -> stem both or keep) # We can split hyphenated words to handle them better in Indonesian sub_tokens = token.split('-') for st in sub_tokens: st = st.strip() if st and st not in ALL_STOPWORDS and len(st) > 2: stemmed = stem_word(st) if stemmed and stemmed not in ALL_STOPWORDS and len(stemmed) > 2: clean_tokens.append(stemmed) # Reconstruct clean_text from clean_tokens reconstructed_text = " ".join(clean_tokens) return reconstructed_text, clean_tokens def run_preprocessing_pipeline(input_path: str = "data/raw_articles.json", output_path: str = "data/preprocessed_articles.json") -> str: """ Loads raw articles, preprocesses each, and saves the cleaned dataset. """ # Load stem cache from disk if it exists load_stem_cache() logger.info(f"Loading raw dataset from: {input_path}") if not os.path.exists(input_path): raise FileNotFoundError(f"Input file {input_path} does not exist.") with open(input_path, 'r', encoding='utf-8') as f: articles = json.load(f) logger.info(f"Preprocessing {len(articles)} articles...") preprocessed_articles = [] # Track progress and cache performance total = len(articles) for idx, art in enumerate(articles, 1): raw_text = art.get("raw_text", "") clean_text, clean_tokens = preprocess_article(raw_text) # Create a new dictionary to preserve original fields and append preprocessed fields new_art = art.copy() new_art["clean_text"] = clean_text new_art["clean_tokens"] = clean_tokens preprocessed_articles.append(new_art) if idx % 20 == 0 or idx == total: logger.info(f"Processed {idx}/{total} articles. Stem cache size: {len(STEM_CACHE)}") # Save back to JSON os.makedirs(os.path.dirname(output_path), exist_ok=True) try: with open(output_path, 'w', encoding='utf-8') as f: json.dump(preprocessed_articles, f, indent=4, ensure_ascii=False) logger.info(f"Preprocessing complete. Cleaned dataset saved to: {output_path}") except Exception as e: logger.error(f"Failed to save preprocessed dataset: {e}") raise e # Save cache back to disk save_stem_cache() return output_path if __name__ == "__main__": # Test on dummy text sample_text = ( "Industri FMCG di Indonesia tengah menerapkan teknologi digital untuk meningkatkan efisiensi. " "Peritel modern versus koperasi tradisional saling bersaing memperebutkan hati para konsumen." ) print("Testing preprocess_article on sample text:") clean_txt, tokens = preprocess_article(sample_text) print("Cleaned text:", clean_txt) print("Tokens:", tokens) # Run pipeline on test data if exists test_input = "data/raw_articles.json" test_output = "data/preprocessed_articles.json" if os.path.exists(test_input): print("\nRunning pipeline on raw_articles.json...") run_preprocessing_pipeline(test_input, test_output)