Archived
refactor: improve duunitori parser with better error handling and code organization
This commit is contained in:
+197
-71
@@ -8,99 +8,225 @@ from lib.exceptions import FetchException
|
|||||||
from models.job_listing import JobListing
|
from models.job_listing import JobListing
|
||||||
|
|
||||||
|
|
||||||
class _fetch_details(BaseModel):
|
class _DuunitoriDetails(BaseModel):
|
||||||
|
"""Model for storing job details fetched from Duunitori."""
|
||||||
|
|
||||||
description: str
|
description: str
|
||||||
apply_url: str
|
apply_url: str
|
||||||
business_id: str
|
business_id: str
|
||||||
|
|
||||||
|
|
||||||
async def _get_description(url: str, client: AsyncClient) -> _fetch_details:
|
async def _fetch_job_details(url: str, client: AsyncClient) -> _DuunitoriDetails:
|
||||||
logger.debug(f"Fetching description from {url}")
|
"""Fetch job details from a Duunitori job posting page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL of the job posting
|
||||||
|
client: HTTP client for making requests
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
_DuunitoriDetails: Parsed job details
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FetchException: If the request fails
|
||||||
|
"""
|
||||||
|
logger.debug(f"Fetching job details from {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to fetch description from {url}", extra={"response": response}
|
f"Failed to fetch job details from {url}", extra={"response": response}
|
||||||
)
|
)
|
||||||
raise FetchException(response.url, response.status_code)
|
raise FetchException(response.url, response.status_code)
|
||||||
|
|
||||||
html = HTMLParser(response.text)
|
html = HTMLParser(response.text)
|
||||||
description = html.css_first(".description-box").html
|
|
||||||
try:
|
# Extract description
|
||||||
apply_url = html.css_first(".gtm-apply-top-button").attributes["href"]
|
description_element = html.css_first(".description-box")
|
||||||
except AttributeError:
|
description = description_element.html if description_element else ""
|
||||||
|
|
||||||
|
# Extract apply URL
|
||||||
apply_url = ""
|
apply_url = ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
business_id = html.css_first("span[itemprop='vatId']").text()
|
apply_button = html.css_first(".gtm-apply-top-button")
|
||||||
|
if apply_button and apply_button.attributes:
|
||||||
|
apply_url = apply_button.attributes.get("href", "")
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
business_id = ""
|
pass
|
||||||
|
|
||||||
client.timeout = None
|
# Extract business ID
|
||||||
return _fetch_details(
|
business_id = ""
|
||||||
description=markdownify(description),
|
try:
|
||||||
|
business_id_element = html.css_first("span[itemprop='vatId']")
|
||||||
|
if business_id_element:
|
||||||
|
business_id = business_id_element.text()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return _DuunitoriDetails(
|
||||||
|
description=markdownify(description) if description else "",
|
||||||
apply_url=apply_url,
|
apply_url=apply_url,
|
||||||
business_id=business_id,
|
business_id=business_id,
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching job details from {url}",
|
||||||
|
extra={"error": str(e), "response": response},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def parse_duunitori(search_term: str):
|
def _parse_job_listing_element(job_element) -> dict[str, str] | None:
|
||||||
retval: list[JobListing] = []
|
"""Parse a single job listing element from the search results page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
job_element: HTML element representing a job listing
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with job data or None if invalid
|
||||||
|
"""
|
||||||
|
if "data-job-slug" not in job_element.attributes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
slug = job_element.attributes["data-job-slug"]
|
||||||
|
company = job_element.attributes.get("data-company", "")
|
||||||
|
title = job_element.text()
|
||||||
|
url = f"https://duunitori.fi/tyopaikat/tyo/{slug}"
|
||||||
|
|
||||||
|
return {"url": url, "company": company, "title": title}
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_search_results_page(
|
||||||
|
search_term: str, page: int, client: AsyncClient
|
||||||
|
) -> HTMLParser:
|
||||||
|
"""Fetch a single page of search results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_term: Term to search for
|
||||||
|
page: Page number to fetch
|
||||||
|
client: HTTP client for making requests
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLParser: Parsed HTML content
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FetchException: If the request fails
|
||||||
|
"""
|
||||||
|
url = f"https://duunitori.fi/tyopaikat?haku={search_term}"
|
||||||
|
if page > 1:
|
||||||
|
url += f"&page={page}"
|
||||||
|
|
||||||
|
logger.debug(f"Fetching search results page {page} from {url}")
|
||||||
|
|
||||||
|
response = await client.get(url)
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to fetch search results page {page} from {url}",
|
||||||
|
extra={"response": response},
|
||||||
|
)
|
||||||
|
raise FetchException(response.url, response.status_code)
|
||||||
|
|
||||||
|
return HTMLParser(response.text)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_total_pages(html: HTMLParser) -> int:
|
||||||
|
"""Extract total number of pages from the search results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: Parsed HTML of the first search results page
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Total number of pages
|
||||||
|
"""
|
||||||
|
pagination_elements = html.css(".pagination__pagenum")
|
||||||
|
if not pagination_elements:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
return int(pagination_elements[-1].text())
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_job_listings(
|
||||||
|
html: HTMLParser, client: AsyncClient
|
||||||
|
) -> list[JobListing]:
|
||||||
|
"""Process job listings from a search results page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: Parsed HTML of a search results page
|
||||||
|
client: HTTP client for making requests
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[JobListing]: List of parsed job listings
|
||||||
|
"""
|
||||||
|
job_listings = []
|
||||||
|
|
||||||
|
for job_element in html.css(".gtm-search-result"):
|
||||||
|
job_data = _parse_job_listing_element(job_element)
|
||||||
|
if not job_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch additional details from the job page
|
||||||
|
details = await _fetch_job_details(job_data["url"], client)
|
||||||
|
|
||||||
|
# Create JobListing object
|
||||||
|
job_listing = JobListing(
|
||||||
|
url=job_data["url"],
|
||||||
|
company=job_data["company"],
|
||||||
|
title=job_data["title"],
|
||||||
|
description=details.description,
|
||||||
|
apply_url=details.apply_url,
|
||||||
|
business_id=details.business_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
job_listings.append(job_listing)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing job {job_data['url']}: {str(e)}")
|
||||||
|
# Continue with other jobs even if one fails
|
||||||
|
continue
|
||||||
|
|
||||||
|
return job_listings
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_duunitori(search_term: str) -> list[JobListing]:
|
||||||
|
"""Parse job listings from Duunitori.fi based on a search term.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_term: Term to search for job listings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[JobListing]: List of parsed job listings
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FetchException: If unable to fetch data from Duunitori
|
||||||
|
"""
|
||||||
|
all_job_listings = []
|
||||||
|
|
||||||
async with AsyncClient() as client:
|
async with AsyncClient() as client:
|
||||||
# Fetch job postings
|
# Fetch first page to determine pagination
|
||||||
response = await client.get(
|
first_page_html = await _fetch_search_results_page(search_term, 1, client)
|
||||||
f"https://duunitori.fi/tyopaikat?haku={search_term}"
|
total_pages = _get_total_pages(first_page_html)
|
||||||
)
|
|
||||||
|
|
||||||
# Check if request was successful
|
logger.debug(f"Found {total_pages} pages of search results")
|
||||||
if response.status_code != 200:
|
|
||||||
raise FetchException(response.url, response.status_code)
|
|
||||||
|
|
||||||
html = HTMLParser(response.text)
|
# Process first page
|
||||||
# Get pagination total pages
|
first_page_listings = await _process_job_listings(first_page_html, client)
|
||||||
pagination_lastpage = int(html.css(".pagination__pagenum")[-1].text())
|
all_job_listings.extend(first_page_listings)
|
||||||
|
|
||||||
logger.debug(f"Pagination last page: {pagination_lastpage}")
|
# Process additional pages if they exist
|
||||||
|
if total_pages > 1:
|
||||||
|
for page in range(2, total_pages + 1):
|
||||||
|
try:
|
||||||
|
page_html = await _fetch_search_results_page(
|
||||||
|
search_term, page, client
|
||||||
|
)
|
||||||
|
page_listings = await _process_job_listings(page_html, client)
|
||||||
|
all_job_listings.extend(page_listings)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing page {page}: {str(e)}")
|
||||||
|
# Continue with other pages even if one fails
|
||||||
|
continue
|
||||||
|
|
||||||
# Page 1 results
|
return all_job_listings
|
||||||
for job_listing in html.css(".gtm-search-result"):
|
|
||||||
if "data-job-slug" in job_listing.attributes:
|
|
||||||
url = f"https://duunitori.fi/tyopaikat/tyo/{job_listing.attributes['data-job-slug']}"
|
|
||||||
company = job_listing.attributes["data-company"]
|
|
||||||
title = job_listing.text()
|
|
||||||
details = await _get_description(url, client)
|
|
||||||
retval.append(
|
|
||||||
JobListing(
|
|
||||||
url=url,
|
|
||||||
company=company,
|
|
||||||
title=title,
|
|
||||||
description=details.description,
|
|
||||||
apply_url=details.apply_url,
|
|
||||||
business_id=details.business_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if pagination_lastpage > 1:
|
|
||||||
for page in range(2, pagination_lastpage + 1):
|
|
||||||
response = await client.get(
|
|
||||||
f"https://duunitori.fi/tyopaikat?haku={search_term}&page={page}"
|
|
||||||
)
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise FetchException(response.url, response.status_code)
|
|
||||||
html = HTMLParser(response.text)
|
|
||||||
job_listings = html.css(".gtm-search-result")
|
|
||||||
for job_listing in job_listings:
|
|
||||||
if "data-job-slug" in job_listing.attributes:
|
|
||||||
url = f"https://duunitori.fi/tyopaikat/tyo/{job_listing.attributes['data-job-slug']}"
|
|
||||||
company = job_listing.attributes["data-company"]
|
|
||||||
title = job_listing.text()
|
|
||||||
details = await _get_description(url, client)
|
|
||||||
|
|
||||||
retval.append(
|
|
||||||
JobListing(
|
|
||||||
url=url,
|
|
||||||
company=company,
|
|
||||||
title=title,
|
|
||||||
description=details.description,
|
|
||||||
apply_url=details.apply_url,
|
|
||||||
business_id=details.business_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return retval
|
|
||||||
|
|||||||
Reference in New Issue
Block a user