File size: 10,432 Bytes
a993d2e
27ffc44
 
d861e5a
 
aed3267
 
27ffc44
eb29d79
 
aed3267
 
eb29d79
 
 
 
 
 
 
 
 
 
 
 
 
 
27ffc44
eb29d79
d861e5a
aed3267
 
eb29d79
 
aed3267
d861e5a
eb29d79
 
 
aed3267
eb29d79
 
aed3267
eb29d79
 
 
 
 
 
 
 
 
 
 
 
aed3267
 
 
eb29d79
aed3267
 
eb29d79
aed3267
eb29d79
aed3267
eb29d79
aed3267
 
eb29d79
 
aed3267
eb29d79
 
 
1898e01
2b19d86
 
eb29d79
 
 
 
aed3267
 
64964db
eb29d79
64964db
aed3267
 
 
eb29d79
1898e01
eb29d79
7c77b88
eb29d79
 
 
aed3267
 
 
eb29d79
 
 
 
 
 
 
 
 
 
 
 
aed3267
eb29d79
aed3267
eb29d79
 
 
 
 
aed3267
 
 
 
 
 
eb29d79
 
 
aed3267
 
eb29d79
 
 
aed3267
eb29d79
aed3267
eb29d79
 
aed3267
eb29d79
 
 
 
 
aed3267
eb29d79
 
aed3267
 
eb29d79
 
 
 
 
 
aed3267
 
eb29d79
 
 
aed3267
 
 
 
eb29d79
 
 
aed3267
 
 
eb29d79
aed3267
eb29d79
 
aed3267
eb29d79
aed3267
eb29d79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aed3267
 
eb29d79
aed3267
eb29d79
 
aed3267
eb29d79
 
 
 
 
 
 
 
 
 
aed3267
eb29d79
 
aed3267
 
 
 
eb29d79
 
 
aed3267
eb29d79
aed3267
eb29d79
 
 
aed3267
 
eb29d79
 
 
aed3267
eb29d79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aed3267
 
eb29d79
aed3267
 
eb29d79
 
 
aed3267
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
import streamlit as st
import requests
from bs4 import BeautifulSoup
import pandas as pd
import re
from urllib.parse import urljoin, urlparse
import time

# --- CONFIG & CONSTANTS ---
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
HEADERS = {"User-Agent": USER_AGENT}
EMAIL_REGEX = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
# Improved Phone Regex to avoid matching years/dates
PHONE_REGEX = r'(?:\+?\d{1,3}[-.\s]?)?\(?\d{3,4}\)?[-.\s]?\d{3,4}[-.\s]?\d{3,4}'
TITLES = ["Dr", "Prof", "Mr", "Ms", "Mrs", "Shri", "Smt", "Doctor"]
DESIGNATIONS = [
    "Assistant Professor", "Associate Professor", "Professor", 
    "HOD", "Head of Department", "Lecturer", "Dean", 
    "Director", "Faculty", "Instructor", "Registrar"
]
# Keywords that invalidate a string as a Name
INVALID_NAME_TOKENS = [
    "phone", "email", "contact", "dept", "department", "office", "fax", 
    "mobile", "university", "college", "address", "profile", "research", 
    "publication", "student", "grievance", "cell", "ext", "tel", "call"
]

# --- UTILITY FUNCTIONS ---

def clean_text(text):
    if not text: return ""
    # Remove non-breaking spaces and extra whitespace
    text = text.replace('\xa0', ' ')
    return re.sub(r'\s+', ' ', text).strip()

def is_valid_name(name_str):
    if not name_str: return False
    clean = clean_text(name_str)
    
    # 1. Length constraint
    if not (3 <= len(clean) <= 60): return False
    
    # 2. Reject if contains digits
    if any(char.isdigit() for char in clean): return False
    
    # 3. Reject if contains invalid keywords (case-insensitive)
    lower_clean = clean.lower()
    if any(token in lower_clean for token in INVALID_NAME_TOKENS): return False
    
    # 4. Must contain at least one alphabet
    if not re.search(r'[a-zA-Z]', clean): return False
    
    # 5. Must have at least 2 words OR a known title
    words = clean.split()
    has_title = any(t.lower() in [w.lower().strip('.') for w in words] for t in TITLES)
    if len(words) >= 2 or has_title:
        return True
    
    return False

def score_name(name, base_confidence):
    if not name: return 0
    score = float(base_confidence)
    words = name.split()
    # Bonus for academic titles
    if any(t.lower() in [w.lower().strip('.') for w in words] for t in TITLES):
        score += 1.5
    # Bonus for standard name lengths (2-3 words)
    if 2 <= len(words) <= 3:
        score += 1.0
    # Penalty for very long strings
    if len(name) > 40:
        score -= 0.5
    return score

def extract_designation(text):
    text_lower = text.lower()
    # Sort designations by length descending to match "Assistant Professor" before "Professor"
    for desig in sorted(DESIGNATIONS, key=len, reverse=True):
        if desig.lower() in text_lower:
            return desig
    return ""

# --- SCRAPER ENGINE ---

class FacultyScraper:
    def __init__(self):
        self.visited_urls = set()
        self.candidates = [] # List of dicts: {email, name, phone, designation, score}

    def fetch_soup(self, url):
        try:
            response = requests.get(url, headers=HEADERS, timeout=15, verify=False)
            response.raise_for_status()
            return BeautifulSoup(response.text, 'html.parser')
        except Exception as e:
            return None

    def extract_from_soup(self, soup):
        if not soup: return

        # Strategy A: Block-based (High Confidence)
        # Look for containers that likely hold one person's info
        for block_tag in ['div', 'tr', 'li', 'section', 'article']:
            for block in soup.find_all(block_tag):
                # Only process blocks that contain an email
                block_text = block.get_text(" ", strip=True)
                emails = re.findall(EMAIL_REGEX, block_text)
                if not emails: continue
                
                email = emails[0].lower()
                # Find name: look for headings or bold text within this specific block
                name = ""
                for header in block.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'strong', 'b']):
                    possible = clean_text(header.get_text())
                    if is_valid_name(possible):
                        name = possible
                        break
                
                phone_match = re.search(PHONE_REGEX, block_text)
                phone = phone_match.group(0) if phone_match else ""
                desig = extract_designation(block_text)
                
                if name:
                    self.candidates.append({
                        "Email": email, "Name": name, "Phone": phone, 
                        "Designation": desig, "Score": score_name(name, 3)
                    })

        # Strategy B: Context-based (Medium Confidence)
        full_text = soup.get_text(" ", strip=True)
        for match in re.finditer(EMAIL_REGEX, full_text):
            email = match.group(0).lower()
            # Extract window around email
            start = max(0, match.start() - 150)
            end = min(len(full_text), match.end() + 150)
            context = full_text[start:end]
            
            # Logic: Name usually appears BEFORE the email in text
            pre_context = full_text[max(0, match.start()-100) : match.start()]
            lines = [l.strip() for l in pre_context.split('  ') if l.strip()]
            
            best_context_name = ""
            for line in reversed(lines):
                if is_valid_name(line):
                    best_context_name = line
                    break
            
            if best_context_name:
                self.candidates.append({
                    "Email": email, "Name": best_context_name, 
                    "Phone": re.search(PHONE_REGEX, context).group(0) if re.search(PHONE_REGEX, context) else "",
                    "Designation": extract_designation(context),
                    "Score": score_name(best_context_name, 2)
                })

        # Strategy C: Fallback (Low Confidence)
        all_emails = re.findall(EMAIL_REGEX, full_text)
        for email in all_emails:
            email = email.lower()
            username = email.split('@')[0]
            fallback_name = username.replace('.', ' ').replace('_', ' ').title()
            if len(fallback_name) > 3:
                self.candidates.append({
                    "Email": email, "Name": fallback_name, "Phone": "", 
                    "Designation": "", "Score": 1.0
                })

    def run(self, start_url):
        # 1. Crawl Logic
        self.visited_urls.add(start_url)
        soup = self.fetch_soup(start_url)
        if not soup: return pd.DataFrame(columns=["Name", "Email", "Phone", "Designation"])
        
        self.extract_from_soup(soup)
        
        # Follow links (Max 5 pages total)
        links = soup.find_all('a', href=True)
        crawled_count = 1
        for link in links:
            if crawled_count >= 5: break
            href = urljoin(start_url, link['href'])
            parsed = urlparse(href)
            # Stay on same domain, look for keywords
            if parsed.netloc == urlparse(start_url).netloc:
                if any(k in href.lower() for k in ["faculty", "staff", "profile", "people", "directory"]):
                    if href not in self.visited_urls:
                        self.visited_urls.add(href)
                        sub_soup = self.fetch_soup(href)
                        if sub_soup:
                            self.extract_from_soup(sub_soup)
                            crawled_count += 1

        # 2. Process Candidates
        if not self.candidates:
            return pd.DataFrame(columns=["Name", "Email", "Phone", "Designation"])

        df = pd.DataFrame(self.candidates)
        
        # 3. Aggressive Deduplication and Selection
        # Group by Email and pick the candidate with the highest score
        df = df.sort_values(by="Score", ascending=False)
        # Clean specific columns before final drop
        df['Name'] = df['Name'].apply(clean_text)
        df['Phone'] = df['Phone'].apply(clean_text)
        df['Designation'] = df['Designation'].apply(clean_text)
        
        # Remove records where Email is clearly invalid (optional but safer)
        df = df[df['Email'].str.contains(r'[^@]+@[^@]+\.[^@]+')]
        
        # Final unique selection per Email
        df_final = df.groupby('Email').first().reset_index()
        
        # Final column ordering
        return df_final[["Name", "Email", "Phone", "Designation"]]

# --- STREAMLIT UI ---

def main():
    st.set_page_config(page_title="Faculty Scraper Pro", layout="wide")
    st.title("🏛️ Production-Grade Faculty Extractor")
    st.markdown("Extracts clean, deduplicated faculty data using multi-strategy block analysis.")

    url = st.text_input("Enter Faculty Directory URL:", placeholder="https://www.university.edu/science/faculty")
    
    if st.button("Start Extraction", type="primary"):
        if not url:
            st.error("Please enter a URL first.")
            return
            
        with st.spinner("Analyzing pages and resolving duplicates..."):
            scraper = FacultyScraper()
            results_df = scraper.run(url)
            
            if not results_df.empty:
                st.success(f"Extracted {len(results_df)} unique faculty members.")
                
                # Cleanup display: Ensure no weird characters or messed up rows
                results_df = results_df.fillna("").astype(str)
                
                st.dataframe(results_df, use_container_width=True)
                
                csv = results_df.to_csv(index=False).encode('utf-8')
                st.download_button(
                    label="Download Data as CSV",
                    data=csv,
                    file_name="faculty_data.csv",
                    mime="text/csv",
                )
            else:
                st.warning("No faculty data found. Ensure the URL is a public directory page.")

    st.divider()
    st.caption("Built with Python, BeautifulSoup, and pandas. Designed for production-grade robustness.")

if __name__ == "__main__":
    # Disable warnings for self-signed certificates common in edu sites
    import urllib3
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
    main()