Add 8 site adapters, early stop, Telegram notifications, and list command
New site adapters: - usajobs: multi-keyword sweep (dentist/orthodontics/orthodontist), URL pagination - docshealth, southernortho: Paylocity platform, keyword in URL - pdshealth, saltdental: iCIMS Angular platform, URL pagination - hospitaljobsonline: Load More button, relative URL fix - aaoinfo: AAO Career Center, click-based AJAX pagination - aroragroup: Load More + post-scrape keyword filter Core improvements: - Early stop pagination: stops when all fingerprints on a page are already in DB - multi_keyword_mode: separate — one scrape per keyword, cross-sweep dedup - gimme-job list command to view collected postings - Stored column in status command - Telegram notification (replacing KakaoTalk) - DB repo: count_by_site(), get_existing_fingerprints() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
8028d5e566
commit
83fb8542a9
@ -0,0 +1,59 @@
|
|||||||
|
"""AAO Career Center adapter — click-based AJAX pagination."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from gimme_job.adapters.base import ManifestDrivenAdapter
|
||||||
|
from gimme_job.adapters.registry import register
|
||||||
|
from gimme_job.models.dto import JobPostingCandidate, RawJobCard
|
||||||
|
|
||||||
|
|
||||||
|
@register("aaoinfo")
|
||||||
|
class AAOInfoAdapter(ManifestDrivenAdapter):
|
||||||
|
_BASE_URL = "https://careers.aaoinfo.org"
|
||||||
|
|
||||||
|
def _wait_for_results(self, page) -> None:
|
||||||
|
try:
|
||||||
|
page.wait_for_selector("div.job-tile", timeout=15000, state="attached")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[aaoinfo] Results wait timed out — may be zero results")
|
||||||
|
|
||||||
|
def collect_cards(self, page, manifest):
|
||||||
|
self._wait_for_results(page)
|
||||||
|
return super().collect_cards(page, manifest)
|
||||||
|
|
||||||
|
def paginate(self, page, page_index: int, config) -> bool:
|
||||||
|
"""Click the next page number button in the AJAX pagination."""
|
||||||
|
if page_index >= config.pagination.max_pages - 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
next_page = page_index + 2
|
||||||
|
|
||||||
|
# Try clicking the specific page number button
|
||||||
|
next_btn = page.query_selector(f"#page-item-{next_page} a")
|
||||||
|
if not next_btn:
|
||||||
|
# Fall back to the last non-active/non-disabled pagination link
|
||||||
|
next_btn = page.query_selector(
|
||||||
|
"ul.pagination li.page-item:not(.active):not(.disabled):last-child a"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not next_btn:
|
||||||
|
logger.debug(f"[aaoinfo] No next page button found — stopping at page {page_index + 1}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.debug(f"[aaoinfo] Paginating to page {next_page}")
|
||||||
|
next_btn.click()
|
||||||
|
|
||||||
|
# Wait for the new page's active indicator to confirm AJAX completed
|
||||||
|
try:
|
||||||
|
page.wait_for_selector(f"#page-item-{next_page}.active", timeout=12000)
|
||||||
|
except Exception:
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
|
||||||
|
candidate = super().normalize(raw)
|
||||||
|
if candidate.job_url and candidate.job_url.startswith("/"):
|
||||||
|
candidate.job_url = self._BASE_URL + candidate.job_url
|
||||||
|
return candidate
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
"""Arora Group Jobs adapter — Load More button, keyword post-filter."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from gimme_job.adapters.base import ManifestDrivenAdapter
|
||||||
|
from gimme_job.adapters.registry import register
|
||||||
|
from gimme_job.models.dto import JobPostingCandidate, RawJobCard
|
||||||
|
|
||||||
|
_KEYWORDS = ["dentist", "orthodontic", "orthodontist"]
|
||||||
|
_MAX_LOAD_MORE = 3 # clicks in addition to the initial load
|
||||||
|
|
||||||
|
|
||||||
|
@register("aroragroup")
|
||||||
|
class AroraGroupAdapter(ManifestDrivenAdapter):
|
||||||
|
_BASE_URL = "https://jobs.aroragroup.com"
|
||||||
|
|
||||||
|
def collect_cards(self, page, manifest):
|
||||||
|
"""Load initial results, click 'Load More' up to N times, then filter by keywords."""
|
||||||
|
try:
|
||||||
|
page.wait_for_selector(".job-post-row", timeout=15000, state="attached")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[aroragroup] Initial results wait timed out")
|
||||||
|
|
||||||
|
for i in range(_MAX_LOAD_MORE):
|
||||||
|
btn = page.query_selector("button#loadMore")
|
||||||
|
if not btn or not btn.is_visible():
|
||||||
|
logger.debug(f"[aroragroup] Load More button gone after {i} extra loads")
|
||||||
|
break
|
||||||
|
|
||||||
|
current_count = len(page.query_selector_all(".job-post-row[onclick]"))
|
||||||
|
btn.click()
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.wait_for_function(
|
||||||
|
f"document.querySelectorAll('.job-post-row[onclick]').length > {current_count}",
|
||||||
|
timeout=15000,
|
||||||
|
)
|
||||||
|
logger.debug(f"[aroragroup] Load More {i + 1}/{_MAX_LOAD_MORE}: loaded more cards")
|
||||||
|
except Exception:
|
||||||
|
logger.debug(f"[aroragroup] Load More {i + 1}: no new cards appeared — stopping")
|
||||||
|
break
|
||||||
|
|
||||||
|
all_cards = super().collect_cards(page, manifest)
|
||||||
|
|
||||||
|
# Filter by keywords in title
|
||||||
|
filtered = [c for c in all_cards if self._matches_keywords(c.title)]
|
||||||
|
logger.debug(
|
||||||
|
f"[aroragroup] Keyword filter: {len(filtered)}/{len(all_cards)} cards matched"
|
||||||
|
)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
def paginate(self, page, page_index: int, config) -> bool:
|
||||||
|
"""Pagination is handled inside collect_cards — always return False."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
|
||||||
|
candidate = super().normalize(raw)
|
||||||
|
if candidate.job_url and candidate.job_url.startswith("/"):
|
||||||
|
candidate.job_url = self._BASE_URL + candidate.job_url
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _matches_keywords(title: str | None) -> bool:
|
||||||
|
if not title:
|
||||||
|
return False
|
||||||
|
title_lower = title.lower()
|
||||||
|
return any(kw in title_lower for kw in _KEYWORDS)
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
"""DOCS Health adapter — Paylocity-hosted job board, single-page results."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from gimme_job.adapters.base import ManifestDrivenAdapter
|
||||||
|
from gimme_job.adapters.registry import register
|
||||||
|
from gimme_job.models.dto import JobPostingCandidate, RawJobCard
|
||||||
|
|
||||||
|
|
||||||
|
@register("docshealth")
|
||||||
|
class DocsHealthAdapter(ManifestDrivenAdapter):
|
||||||
|
_BASE_URL = "https://recruiting.paylocity.com"
|
||||||
|
_COMPANY = "DOCS Health"
|
||||||
|
|
||||||
|
def collect_cards(self, page, manifest):
|
||||||
|
# Wait for job listings to render
|
||||||
|
try:
|
||||||
|
page.wait_for_selector(".job-listing-job-item", timeout=12000, state="attached")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[docshealth] Results wait timed out — may be zero results")
|
||||||
|
return super().collect_cards(page, manifest)
|
||||||
|
|
||||||
|
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
|
||||||
|
candidate = super().normalize(raw)
|
||||||
|
# Fix relative URL → absolute
|
||||||
|
if candidate.job_url and candidate.job_url.startswith("/"):
|
||||||
|
candidate.job_url = self._BASE_URL + candidate.job_url
|
||||||
|
# Company is always DOCS Health (not in per-card HTML)
|
||||||
|
candidate.company = self._COMPANY
|
||||||
|
# Clean posted_text: "03/27/2026 - " → "03/27/2026"
|
||||||
|
if candidate.posted_text:
|
||||||
|
candidate.posted_text = candidate.posted_text.replace(" - ", "").strip()
|
||||||
|
return candidate
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
"""Hospital Jobs Online adapter — URL-based pagination with page= parameter."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from gimme_job.adapters.base import ManifestDrivenAdapter
|
||||||
|
from gimme_job.adapters.registry import register
|
||||||
|
from gimme_job.models.dto import JobPostingCandidate, RawJobCard
|
||||||
|
|
||||||
|
|
||||||
|
@register("hospitaljobsonline")
|
||||||
|
class HospitalJobsOnlineAdapter(ManifestDrivenAdapter):
|
||||||
|
_BASE_URL = "https://www.hospitaljobsonline.com"
|
||||||
|
|
||||||
|
def _wait_for_results(self, page) -> None:
|
||||||
|
try:
|
||||||
|
page.wait_for_selector("div.jobresult", timeout=15000, state="attached")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[hospitaljobsonline] Results wait timed out — may be zero results")
|
||||||
|
|
||||||
|
def collect_cards(self, page, manifest):
|
||||||
|
self._wait_for_results(page)
|
||||||
|
return super().collect_cards(page, manifest)
|
||||||
|
|
||||||
|
def paginate(self, page, page_index: int, config) -> bool:
|
||||||
|
"""Navigate to next page by incrementing the ?page= URL parameter."""
|
||||||
|
if page_index >= config.pagination.max_pages - 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_url = page.url
|
||||||
|
next_page = page_index + 2
|
||||||
|
|
||||||
|
if re.search(r"[?&]page=\d+", current_url):
|
||||||
|
next_url = re.sub(r"(page=)\d+", f"page={next_page}", current_url)
|
||||||
|
else:
|
||||||
|
sep = "&" if "?" in current_url else "?"
|
||||||
|
next_url = f"{current_url}{sep}page={next_page}"
|
||||||
|
|
||||||
|
logger.debug(f"[hospitaljobsonline] Paginating to page {next_page}: {next_url}")
|
||||||
|
page.goto(next_url, timeout=30000)
|
||||||
|
self._wait_for_results(page)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
|
||||||
|
candidate = super().normalize(raw)
|
||||||
|
|
||||||
|
if candidate.job_url and candidate.job_url.startswith("/"):
|
||||||
|
candidate.job_url = self._BASE_URL + candidate.job_url
|
||||||
|
|
||||||
|
# Parse location text: "1 week ago - Company Name - Location: City, ST"
|
||||||
|
if candidate.location:
|
||||||
|
loc = candidate.location
|
||||||
|
# Extract part after "Location:" if present
|
||||||
|
m = re.search(r"Location:\s*(.+)$", loc, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
candidate.location = m.group(1).strip()
|
||||||
|
else:
|
||||||
|
# Fallback: take last segment after " - "
|
||||||
|
parts = [p.strip() for p in loc.split(" - ")]
|
||||||
|
if len(parts) >= 2:
|
||||||
|
candidate.location = parts[-1]
|
||||||
|
|
||||||
|
return candidate
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
"""PDS Health adapter — Angular Material job board, URL-based pagination."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from gimme_job.adapters.base import ManifestDrivenAdapter
|
||||||
|
from gimme_job.adapters.registry import register
|
||||||
|
from gimme_job.models.dto import JobPostingCandidate, RawJobCard
|
||||||
|
|
||||||
|
|
||||||
|
@register("pdshealth")
|
||||||
|
class PdsHealthAdapter(ManifestDrivenAdapter):
|
||||||
|
|
||||||
|
def _wait_for_results(self, page) -> None:
|
||||||
|
try:
|
||||||
|
page.wait_for_selector(
|
||||||
|
"mat-expansion-panel.search-result-item",
|
||||||
|
timeout=15000,
|
||||||
|
state="attached",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[pdshealth] Results wait timed out — may be zero results")
|
||||||
|
|
||||||
|
def collect_cards(self, page, manifest):
|
||||||
|
self._wait_for_results(page)
|
||||||
|
return super().collect_cards(page, manifest)
|
||||||
|
|
||||||
|
def paginate(self, page, page_index: int, config) -> bool:
|
||||||
|
"""Navigate to next page by incrementing the &page= URL parameter."""
|
||||||
|
max_pages = config.pagination.max_pages
|
||||||
|
if page_index >= max_pages - 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_url = page.url
|
||||||
|
next_page = page_index + 2 # 0-based index → 1-based page param
|
||||||
|
|
||||||
|
if re.search(r"[?&]page=\d+", current_url):
|
||||||
|
next_url = re.sub(r"(page=)\d+", f"page={next_page}", current_url)
|
||||||
|
else:
|
||||||
|
sep = "&" if "?" in current_url else "?"
|
||||||
|
next_url = f"{current_url}{sep}page={next_page}"
|
||||||
|
|
||||||
|
logger.debug(f"[pdshealth] Paginating to page {next_page}: {next_url}")
|
||||||
|
page.goto(next_url, timeout=30000)
|
||||||
|
self._wait_for_results(page)
|
||||||
|
return True
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
"""Salt Dental Partners adapter — iCIMS Angular platform, URL-based pagination."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from gimme_job.adapters.base import ManifestDrivenAdapter
|
||||||
|
from gimme_job.adapters.registry import register
|
||||||
|
|
||||||
|
|
||||||
|
@register("saltdental")
|
||||||
|
class SaltDentalAdapter(ManifestDrivenAdapter):
|
||||||
|
|
||||||
|
def _wait_for_results(self, page) -> None:
|
||||||
|
try:
|
||||||
|
page.wait_for_selector(
|
||||||
|
"mat-expansion-panel.search-result-item",
|
||||||
|
timeout=15000,
|
||||||
|
state="attached",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[saltdental] Results wait timed out — may be zero results")
|
||||||
|
|
||||||
|
def collect_cards(self, page, manifest):
|
||||||
|
self._wait_for_results(page)
|
||||||
|
return super().collect_cards(page, manifest)
|
||||||
|
|
||||||
|
def paginate(self, page, page_index: int, config) -> bool:
|
||||||
|
"""Navigate to next page by incrementing the &page= URL parameter."""
|
||||||
|
if page_index >= config.pagination.max_pages - 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_url = page.url
|
||||||
|
next_page = page_index + 2
|
||||||
|
|
||||||
|
if re.search(r"[?&]page=\d+", current_url):
|
||||||
|
next_url = re.sub(r"(page=)\d+", f"page={next_page}", current_url)
|
||||||
|
else:
|
||||||
|
sep = "&" if "?" in current_url else "?"
|
||||||
|
next_url = f"{current_url}{sep}page={next_page}"
|
||||||
|
|
||||||
|
logger.debug(f"[saltdental] Paginating to page {next_page}: {next_url}")
|
||||||
|
page.goto(next_url, timeout=30000)
|
||||||
|
self._wait_for_results(page)
|
||||||
|
return True
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
"""Southern Orthodontic Partners adapter — Paylocity platform, single page."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from gimme_job.adapters.base import ManifestDrivenAdapter
|
||||||
|
from gimme_job.adapters.registry import register
|
||||||
|
from gimme_job.models.dto import JobPostingCandidate, RawJobCard
|
||||||
|
|
||||||
|
|
||||||
|
@register("southernortho")
|
||||||
|
class SouthernOrthoAdapter(ManifestDrivenAdapter):
|
||||||
|
_BASE_URL = "https://recruiting.paylocity.com"
|
||||||
|
|
||||||
|
def collect_cards(self, page, manifest):
|
||||||
|
try:
|
||||||
|
page.wait_for_selector(".job-listing-job-item", timeout=12000, state="attached")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[southernortho] Results wait timed out — may be zero results")
|
||||||
|
return super().collect_cards(page, manifest)
|
||||||
|
|
||||||
|
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
|
||||||
|
candidate = super().normalize(raw)
|
||||||
|
if candidate.job_url and candidate.job_url.startswith("/"):
|
||||||
|
candidate.job_url = self._BASE_URL + candidate.job_url
|
||||||
|
if candidate.posted_text:
|
||||||
|
candidate.posted_text = candidate.posted_text.replace(" - ", "").strip()
|
||||||
|
return candidate
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
"""USAJOBS adapter — waits for JS render, handles relative URLs, URL-based pagination."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from gimme_job.adapters.base import ManifestDrivenAdapter
|
||||||
|
from gimme_job.adapters.registry import register
|
||||||
|
from gimme_job.models.dto import JobPostingCandidate, RawJobCard
|
||||||
|
|
||||||
|
|
||||||
|
@register("usajobs")
|
||||||
|
class UsaJobsAdapter(ManifestDrivenAdapter):
|
||||||
|
_BASE_URL = "https://www.usajobs.gov"
|
||||||
|
|
||||||
|
def _wait_for_results(self, page) -> None:
|
||||||
|
"""Wait for JS-rendered results or zero-results heading."""
|
||||||
|
try:
|
||||||
|
page.wait_for_selector(
|
||||||
|
"#search-results .border, h2.font-normal",
|
||||||
|
timeout=12000,
|
||||||
|
state="attached",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[usajobs] Results wait timed out — may be zero results")
|
||||||
|
|
||||||
|
def collect_cards(self, page, manifest):
|
||||||
|
self._wait_for_results(page)
|
||||||
|
return super().collect_cards(page, manifest)
|
||||||
|
|
||||||
|
def paginate(self, page, page_index: int, config) -> bool:
|
||||||
|
"""Navigate to next page by incrementing the &p= URL parameter."""
|
||||||
|
max_pages = config.pagination.max_pages
|
||||||
|
if page_index >= max_pages - 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_url = page.url
|
||||||
|
next_page = page_index + 2 # page_index is 0-based; URL param is 1-based
|
||||||
|
|
||||||
|
# Replace &p=N or add &p=N
|
||||||
|
if re.search(r"[?&]p=\d+", current_url):
|
||||||
|
next_url = re.sub(r"(p=)\d+", f"p={next_page}", current_url)
|
||||||
|
else:
|
||||||
|
sep = "&" if "?" in current_url else "?"
|
||||||
|
next_url = f"{current_url}{sep}p={next_page}"
|
||||||
|
|
||||||
|
logger.debug(f"[usajobs] Paginating to page {next_page}: {next_url}")
|
||||||
|
page.goto(next_url, timeout=30000)
|
||||||
|
self._wait_for_results(page)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
|
||||||
|
candidate = super().normalize(raw)
|
||||||
|
# USAJOBS job links are relative: /job/123456 → absolute URL
|
||||||
|
if candidate.job_url and candidate.job_url.startswith("/"):
|
||||||
|
candidate.job_url = self._BASE_URL + candidate.job_url
|
||||||
|
return candidate
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
"""Telegram notification client — sends messages via Bot API."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
TELEGRAM_API_URL = "https://api.telegram.org/bot{token}/sendMessage"
|
||||||
|
MAX_TEXT_LENGTH = 4096 # Telegram message limit
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bot_token: Optional[str] = None,
|
||||||
|
chat_id: Optional[str] = None,
|
||||||
|
):
|
||||||
|
self.bot_token = bot_token or os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
self.chat_id = chat_id or os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||||
|
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
return bool(self.bot_token and self.chat_id)
|
||||||
|
|
||||||
|
def send_message(self, text: str) -> bool:
|
||||||
|
"""Send a message to the configured chat. Returns True on success."""
|
||||||
|
if not self.is_configured():
|
||||||
|
logger.warning("Telegram not configured (missing BOT_TOKEN or CHAT_ID)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(text) > MAX_TEXT_LENGTH:
|
||||||
|
text = text[: MAX_TEXT_LENGTH - 20] + "\n\n[...truncated]"
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
response = httpx.post(
|
||||||
|
TELEGRAM_API_URL.format(token=self.bot_token),
|
||||||
|
json={
|
||||||
|
"chat_id": self.chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
},
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
if response.status_code == 200 and response.json().get("ok"):
|
||||||
|
logger.info("Telegram message sent successfully")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Telegram API error: {response.text[:200]}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram send failed: {e}")
|
||||||
|
return False
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
site_id: aaoinfo
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: AAO Career Center
|
||||||
|
category: aggregator
|
||||||
|
base_url: https://careers.aaoinfo.org
|
||||||
|
start_url_template: "https://careers.aaoinfo.org/jobs/"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: none
|
||||||
|
location_mode: none
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: "div.job-tile"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: none
|
||||||
|
max_pages: 10
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- "div.job-tile:not(.job-mo)"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- ".job-title a"
|
||||||
|
company:
|
||||||
|
text:
|
||||||
|
- ".job-company-row"
|
||||||
|
location:
|
||||||
|
text:
|
||||||
|
- ".job-location"
|
||||||
|
posted_text:
|
||||||
|
text:
|
||||||
|
- ".job-posted-date"
|
||||||
|
url:
|
||||||
|
attr:
|
||||||
|
selector: ".job-title a"
|
||||||
|
name: href
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
site_id: aroragroup
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: Arora Group Jobs
|
||||||
|
category: employer
|
||||||
|
base_url: https://jobs.aroragroup.com
|
||||||
|
start_url_template: "https://jobs.aroragroup.com/"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: none
|
||||||
|
location_mode: none
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: ".job-post-row"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: none
|
||||||
|
max_pages: 1
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- ".job-post-row[onclick]"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- ".POST_TITLE"
|
||||||
|
- "h3.job-post-title a"
|
||||||
|
location:
|
||||||
|
text:
|
||||||
|
- ".POST_LOCATION"
|
||||||
|
- ".job-post-location"
|
||||||
|
url:
|
||||||
|
attr:
|
||||||
|
selector: "a.job-post-href"
|
||||||
|
name: href
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
site_id: docshealth
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: DOCS Health
|
||||||
|
category: employer
|
||||||
|
base_url: https://recruiting.paylocity.com
|
||||||
|
start_url_template: "https://recruiting.paylocity.com/recruiting/jobs/All/4f5b87cb-fda9-4243-976b-499bbf0ce9fc/DOCS-Health?search={keywords_urlencoded}&location=All%20Locations&department=All%20Departments"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: url_param
|
||||||
|
location_mode: url_param
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: ".job-listing-job-item"
|
||||||
|
|
||||||
|
search_override:
|
||||||
|
keywords:
|
||||||
|
- dentist
|
||||||
|
- orthodontics
|
||||||
|
- orthodontist
|
||||||
|
multi_keyword_mode: separate
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: none
|
||||||
|
max_pages: 1
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- "div.job-listing-job-item"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- ".job-item-title a"
|
||||||
|
location:
|
||||||
|
text:
|
||||||
|
- ".location-column .job-item-normal"
|
||||||
|
posted_text:
|
||||||
|
text:
|
||||||
|
- ".job-title-column > span:not(.job-item-title):not(.job-item-normal)"
|
||||||
|
url:
|
||||||
|
attr:
|
||||||
|
selector: ".job-item-title a"
|
||||||
|
name: href
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
site_id: hospitaljobsonline
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: Hospital Jobs Online
|
||||||
|
category: aggregator
|
||||||
|
base_url: https://www.hospitaljobsonline.com
|
||||||
|
start_url_template: "https://www.hospitaljobsonline.com/jobs/q-korea-jobs//?page=1&limit=100"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: none
|
||||||
|
location_mode: none
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: "div.jobresult"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: url_increment
|
||||||
|
max_pages: 10
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- "div.jobresult"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- ".jobtitle a"
|
||||||
|
company:
|
||||||
|
text:
|
||||||
|
- ".location a"
|
||||||
|
location:
|
||||||
|
text:
|
||||||
|
- ".location"
|
||||||
|
posted_text:
|
||||||
|
text:
|
||||||
|
- ".location .date"
|
||||||
|
url:
|
||||||
|
attr:
|
||||||
|
selector: ".jobtitle a"
|
||||||
|
name: href
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
site_id: linkedin
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: LinkedIn Jobs
|
||||||
|
category: aggregator
|
||||||
|
base_url: https://www.linkedin.com
|
||||||
|
start_url_template: "https://www.linkedin.com/jobs/search/?keywords={keywords_urlencoded}&f_TPR=r86400&sortBy=DD"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: false
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: url_param
|
||||||
|
location_mode: url_param
|
||||||
|
sort_mode: url_param
|
||||||
|
date_mode: url_param
|
||||||
|
result_list_wait_selector: ".jobs-search__results-list li, .scaffold-layout__list-container li"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: next_button
|
||||||
|
next_button_selectors:
|
||||||
|
- "button[aria-label='View next page']"
|
||||||
|
- "button[aria-label*='next']"
|
||||||
|
- "li.artdeco-pagination__indicator--number.selected + li button"
|
||||||
|
max_pages: 3
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- ".jobs-search__results-list li"
|
||||||
|
- ".scaffold-layout__list-container li"
|
||||||
|
- "li:has(.base-card)"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- "h3.base-search-card__title"
|
||||||
|
- ".job-card-list__title"
|
||||||
|
- "h3"
|
||||||
|
- "a[data-control-name='job_card_title']"
|
||||||
|
company:
|
||||||
|
text:
|
||||||
|
- "h4.base-search-card__subtitle"
|
||||||
|
- ".job-card-container__primary-description"
|
||||||
|
- "a.job-card-container__company-name"
|
||||||
|
location:
|
||||||
|
text:
|
||||||
|
- "span.job-search-card__location"
|
||||||
|
- ".job-card-container__metadata-item"
|
||||||
|
posted_text:
|
||||||
|
text:
|
||||||
|
- "time"
|
||||||
|
- ".job-search-card__listdate"
|
||||||
|
- "span[class*='listdate']"
|
||||||
|
url:
|
||||||
|
attr:
|
||||||
|
selector: "a.base-card__full-link, a[data-control-name='job_card_title'], a[href*='/jobs/view/']"
|
||||||
|
name: href
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
site_id: pdshealth
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: PDS Health
|
||||||
|
category: employer
|
||||||
|
base_url: https://jobs.pdshealth.com
|
||||||
|
start_url_template: "https://jobs.pdshealth.com/jobs?keywords=orthodontist&sortBy=relevance&page=1&limit=100"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: none
|
||||||
|
location_mode: none
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: "mat-expansion-panel.search-result-item"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: url_increment
|
||||||
|
max_pages: 20
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- "mat-expansion-panel.search-result-item"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- "a.job-title-link"
|
||||||
|
company:
|
||||||
|
text:
|
||||||
|
- ".label-value.tags1"
|
||||||
|
location:
|
||||||
|
text:
|
||||||
|
- ".label-value.location"
|
||||||
|
url:
|
||||||
|
attr:
|
||||||
|
selector: "a.job-title-link"
|
||||||
|
name: href
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
site_id: saltdental
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: Salt Dental Partners
|
||||||
|
category: employer
|
||||||
|
base_url: https://jobs.saltdentalpartners.com
|
||||||
|
start_url_template: "https://jobs.saltdentalpartners.com/jobs?keywords=orthodontist&sortBy=relevance&page=1"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: none
|
||||||
|
location_mode: none
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: "mat-expansion-panel.search-result-item"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: url_increment
|
||||||
|
max_pages: 5
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- "mat-expansion-panel.search-result-item"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- "a.job-title-link"
|
||||||
|
company:
|
||||||
|
text:
|
||||||
|
- ".label-value.tags1"
|
||||||
|
location:
|
||||||
|
text:
|
||||||
|
- ".label-value.location"
|
||||||
|
url:
|
||||||
|
attr:
|
||||||
|
selector: "a.job-title-link"
|
||||||
|
name: href
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
site_id: southernortho
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: Southern Orthodontic Partners
|
||||||
|
category: employer
|
||||||
|
base_url: https://recruiting.paylocity.com
|
||||||
|
start_url_template: "https://recruiting.paylocity.com/recruiting/jobs/All/06144481-7908-457c-9312-51cf7b58cf01/Southern-Orthodontic-Partners-Mgmt-LLC?search=orthodontist&location=All%20Locations&department=All%20Departments"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: none
|
||||||
|
location_mode: none
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: ".job-listing-job-item"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: none
|
||||||
|
max_pages: 1
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- "div.job-listing-job-item"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- ".job-item-title a"
|
||||||
|
company:
|
||||||
|
text:
|
||||||
|
- ".location-column .job-item-normal"
|
||||||
|
posted_text:
|
||||||
|
text:
|
||||||
|
- ".job-title-column > span:not(.job-item-title):not(.job-item-normal)"
|
||||||
|
url:
|
||||||
|
attr:
|
||||||
|
selector: ".job-item-title a"
|
||||||
|
name: href
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
site_id: usajobs
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: USAJOBS
|
||||||
|
category: government
|
||||||
|
base_url: https://www.usajobs.gov
|
||||||
|
start_url_template: "https://www.usajobs.gov/Search/Results?k={keywords_urlencoded}&p=1"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: url_param
|
||||||
|
location_mode: url_param
|
||||||
|
sort_mode: url_param
|
||||||
|
date_mode: url_param
|
||||||
|
result_list_wait_selector: ""
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: next_button
|
||||||
|
next_button_selectors:
|
||||||
|
- "button#page-m-next"
|
||||||
|
- "button[title='Go to next page']"
|
||||||
|
max_pages: 10
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- "#search-results .border.border-gray-lighter"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- "h2 a"
|
||||||
|
company:
|
||||||
|
text:
|
||||||
|
- "p strong"
|
||||||
|
location:
|
||||||
|
text:
|
||||||
|
- "div:nth-child(2) div:first-child p:nth-child(3)"
|
||||||
|
posted_text:
|
||||||
|
text:
|
||||||
|
- ".italic"
|
||||||
|
url:
|
||||||
|
attr:
|
||||||
|
selector: "h2 a"
|
||||||
|
name: href
|
||||||
|
|
||||||
|
search_override:
|
||||||
|
keywords:
|
||||||
|
- dentist
|
||||||
|
- orthodontics
|
||||||
|
- orthodontist
|
||||||
|
multi_keyword_mode: separate
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
Loading…
Reference in New Issue