1+ #__init__.py
2+ from __future__ import annotations
3+
4+ import random
5+ import time
6+ from datetime import datetime
7+ from typing import Optional , List , Dict , Any
8+ from urllib .parse import urljoin
9+
10+ from bs4 import BeautifulSoup
11+ from bs4 .element import Tag
12+
13+ from jobspy .exception import BDJobsException
14+ from jobspy .bdjobs .constant import headers , search_params
15+ from jobspy .bdjobs .util import parse_location , parse_date , find_job_listings , is_job_remote
16+ from jobspy .model import (
17+ JobPost ,
18+ Location ,
19+ JobResponse ,
20+ Country ,
21+ Scraper ,
22+ ScraperInput ,
23+ Site ,
24+ DescriptionFormat ,
25+ )
26+ from jobspy .util import (
27+ extract_emails_from_text ,
28+ create_session ,
29+ create_logger ,
30+ remove_attributes ,
31+ markdown_converter ,
32+ )
33+
34+ log = create_logger ("BDJobs" )
35+
36+
37+ class BDJobs (Scraper ):
38+ base_url = "https://jobs.bdjobs.com"
39+ search_url = "https://jobs.bdjobs.com/jobsearch.asp"
40+ delay = 2
41+ band_delay = 3
42+
43+ def __init__ (
44+ self , proxies : list [str ] | str | None = None , ca_cert : str | None = None
45+ ):
46+ """
47+ Initializes BDJobsScraper with the BDJobs job search url
48+ """
49+ super ().__init__ (Site .BDJOBS , proxies = proxies , ca_cert = ca_cert )
50+ self .session = create_session (
51+ proxies = self .proxies ,
52+ ca_cert = ca_cert ,
53+ is_tls = False ,
54+ has_retry = True ,
55+ delay = 5 ,
56+ clear_cookies = True ,
57+ )
58+ self .session .headers .update (headers )
59+ self .scraper_input = None
60+ self .country = "bangladesh"
61+
62+ def scrape (self , scraper_input : ScraperInput ) -> JobResponse :
63+ """
64+ Scrapes BDJobs for jobs with scraper_input criteria
65+ :param scraper_input:
66+ :return: job_response
67+ """
68+ self .scraper_input = scraper_input
69+ job_list : list [JobPost ] = []
70+ seen_ids = set ()
71+ page = 1
72+ request_count = 0
73+
74+ # Set up search parameters
75+ params = search_params .copy ()
76+ params ["txtsearch" ] = scraper_input .search_term
77+
78+ continue_search = lambda : len (job_list ) < scraper_input .results_wanted
79+
80+ while continue_search ():
81+ request_count += 1
82+ log .info (f"search page: { request_count } " )
83+
84+ try :
85+ # Add page parameter if needed
86+ if page > 1 :
87+ params ["pg" ] = page
88+
89+ response = self .session .get (
90+ self .search_url ,
91+ params = params ,
92+ timeout = getattr (scraper_input , 'request_timeout' , 60 )
93+ )
94+
95+ # DEBUG: Save the received HTML content
96+ try :
97+ with open ("scraper_received_bdjobs.html" , "w" , encoding = "utf-8" ) as f :
98+ f .write (response .text )
99+ log .info (f"Saved scraper response to scraper_received_bdjobs.html" )
100+ except Exception as e_write :
101+ log .error (f"Error writing debug HTML file: { e_write } " )
102+
103+ if response .status_code != 200 :
104+ log .error (f"BDJobs response status code { response .status_code } " )
105+ break
106+
107+ soup = BeautifulSoup (response .text , "html.parser" )
108+ job_cards = find_job_listings (soup )
109+
110+ if not job_cards or len (job_cards ) == 0 :
111+ log .info ("No more job listings found" )
112+ break
113+
114+ log .info (f"Found { len (job_cards )} job cards on page { page } " )
115+
116+ for job_card in job_cards :
117+ try :
118+ job_post = self ._process_job (job_card )
119+ if job_post and job_post .id not in seen_ids :
120+ seen_ids .add (job_post .id )
121+ job_list .append (job_post )
122+
123+ if not continue_search ():
124+ break
125+ except Exception as e :
126+ log .error (f"Error processing job card: { str (e )} " )
127+
128+ page += 1
129+ # Add delay between requests
130+ time .sleep (random .uniform (self .delay , self .delay + self .band_delay ))
131+
132+ except Exception as e :
133+ log .error (f"Error during scraping: { str (e )} " )
134+ break
135+
136+ job_list = job_list [:scraper_input .results_wanted ]
137+ return JobResponse (jobs = job_list )
138+
139+ def _process_job (self , job_card : Tag ) -> Optional [JobPost ]:
140+ """
141+ Processes a job card element into a JobPost object
142+ :param job_card: Job card element
143+ :return: JobPost object
144+ """
145+ try :
146+ # Extract job ID and URL
147+ job_link = job_card .find ("a" , href = lambda h : h and "jobdetail" in h .lower ())
148+ if not job_link :
149+ return None
150+
151+ job_url = job_link .get ("href" )
152+ if not job_url .startswith ("http" ):
153+ job_url = urljoin (self .base_url , job_url )
154+
155+ # Extract job ID from URL
156+ job_id = job_url .split ("jobid=" )[- 1 ].split ("&" )[0 ] if "jobid=" in job_url else f"bdjobs-{ hash (job_url )} "
157+
158+ # Extract title
159+ title = job_link .get_text (strip = True )
160+ if not title :
161+ title_elem = job_card .find (["h2" , "h3" , "h4" , "strong" , "div" ], class_ = lambda c : c and "job-title-text" in c )
162+ title = title_elem .get_text (strip = True ) if title_elem else "N/A"
163+
164+ # Extract company name - IMPROVED
165+ company_elem = job_card .find (["span" , "div" ], class_ = lambda c : c and "comp-name-text" in (c or "" ).lower ())
166+ if company_elem :
167+ company_name = company_elem .get_text (strip = True )
168+ else :
169+ # Try alternative selectors
170+ company_elem = job_card .find (["span" , "div" ], class_ = lambda c : c and any (term in (c or "" ).lower () for term in ["company" , "org" , "comp-name" ]))
171+ company_name = company_elem .get_text (strip = True ) if company_elem else "N/A"
172+
173+ # Extract location
174+ location_elem = job_card .find (["span" , "div" ], class_ = lambda c : c and "locon-text-d" in (c or "" ).lower ())
175+ if not location_elem :
176+ location_elem = job_card .find (["span" , "div" ], class_ = lambda c : c and any (term in (c or "" ).lower () for term in ["location" , "area" , "locon" ]))
177+ location_text = location_elem .get_text (strip = True ) if location_elem else "Dhaka, Bangladesh"
178+
179+ # Create Location object
180+ location = parse_location (location_text , self .country )
181+
182+ # Extract date posted
183+ date_elem = job_card .find (["span" , "div" ], class_ = lambda c : c and any (term in (c or "" ).lower () for term in ["date" , "deadline" , "published" ]))
184+ date_posted = None
185+ if date_elem :
186+ date_text = date_elem .get_text (strip = True )
187+ date_posted = parse_date (date_text )
188+
189+ # Check if job is remote
190+ is_remote = is_job_remote (title , location = location )
191+
192+ # Create job post object
193+ job_post = JobPost (
194+ id = job_id ,
195+ title = title ,
196+ company_name = company_name , # Use company_name instead of company
197+ location = location ,
198+ date_posted = date_posted ,
199+ job_url = job_url ,
200+ is_remote = is_remote ,
201+ site = self .site ,
202+ )
203+
204+ # Always fetch description for BDJobs
205+ job_details = self ._get_job_details (job_url )
206+ job_post .description = job_details .get ("description" , "" )
207+ job_post .job_type = job_details .get ("job_type" , "" )
208+
209+ return job_post
210+ except Exception as e :
211+ log .error (f"Error in _process_job: { str (e )} " )
212+ return None
213+
214+ def _get_job_details (self , job_url : str ) -> Dict [str , Any ]:
215+ """
216+ Gets detailed job information from the job page
217+ :param job_url: Job page URL
218+ :return: Dictionary with job details
219+ """
220+ try :
221+ response = self .session .get (job_url , timeout = 60 )
222+ if response .status_code != 200 :
223+ return {}
224+
225+ soup = BeautifulSoup (response .text , "html.parser" )
226+
227+ # Find job description - IMPROVED based on correct.py
228+ description = ""
229+
230+ # Try to find the job content div first (as in correct.py)
231+ job_content_div = soup .find ('div' , class_ = 'jobcontent' )
232+ if job_content_div :
233+ # Look for responsibilities section
234+ responsibilities_heading = job_content_div .find ('h4' , id = 'job_resp' ) or job_content_div .find (['h4' , 'h5' ], string = lambda s : s and 'responsibilities' in s .lower ())
235+ if responsibilities_heading :
236+ responsibilities_elements = []
237+ # Find all following elements until the next heading or hr
238+ for sibling in responsibilities_heading .find_next_siblings ():
239+ if sibling .name in ['hr' , 'h4' , 'h5' ]:
240+ break
241+ if sibling .name == 'ul' :
242+ responsibilities_elements .extend (li .get_text (separator = ' ' , strip = True ) for li in sibling .find_all ('li' ))
243+ elif sibling .name == 'p' :
244+ responsibilities_elements .append (sibling .get_text (separator = ' ' , strip = True ))
245+
246+ description = "\n " .join (responsibilities_elements ) if responsibilities_elements else ""
247+
248+ # If no description found yet, try the original approach
249+ if not description :
250+ description_elem = soup .find (["div" , "section" ], class_ = lambda c : c and any (term in (c or "" ).lower () for term in ["job-description" , "details" , "requirements" ]))
251+ if description_elem :
252+ description_elem = remove_attributes (description_elem )
253+ description = description_elem .prettify (formatter = "html" )
254+ if hasattr (self .scraper_input , 'description_format' ) and self .scraper_input .description_format == DescriptionFormat .MARKDOWN :
255+ description = markdown_converter (description )
256+
257+ # Extract job type
258+ job_type_elem = soup .find (["span" , "div" ], string = lambda s : s and any (term in (s or "" ).lower () for term in ["job type" , "employment type" ]))
259+ job_type = None
260+ if job_type_elem :
261+ job_type_text = job_type_elem .find_next (["span" , "div" ]).get_text (strip = True )
262+ job_type = job_type_text if job_type_text else None
263+
264+ # Extract company industry
265+ industry_elem = soup .find (["span" , "div" ], string = lambda s : s and "industry" in (s or "" ).lower ())
266+ company_industry = None
267+ if industry_elem :
268+ industry_text = industry_elem .find_next (["span" , "div" ]).get_text (strip = True )
269+ company_industry = industry_text if industry_text else None
270+
271+ return {
272+ "description" : description ,
273+ "job_type" : job_type ,
274+ "company_industry" : company_industry
275+ }
276+
277+ except Exception as e :
278+ log .error (f"Error getting job details: { str (e )} " )
279+ return {}
0 commit comments