Add Google Jobs & HospitalRecruiting adapters; LinkedIn Volunteer filter; aaoinfo fixes
- Add googlejobs adapter (scroll-based extraction) and manifest - Add hospitalrecruiting adapter (JS extraction) and manifest - Fix googlejobs job URL: use shareUrl directly to preserve #fpstate=tldetail fragment - Add exclude_title_keywords post-filter support (manifest + orchestrator) - LinkedIn: exclude "volunteer" titles via exclude_title_keywords - aaoinfo: exclude Preferred listings via :not(:has(.label-preferred)) selector - aaoinfo: sort=start_ (descending = newest first) - Telegram: multi-chat support, HTML escaping fixes, chunk splitting - Various adapter, notifier, and CLI improvements Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
317f929059
commit
5fa28b3948
@ -0,0 +1,108 @@
|
||||
"""Google Jobs adapter — scroll to load more results, JS extraction."""
|
||||
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 RawJobCard
|
||||
|
||||
_MAX_SCROLLS = 5
|
||||
_SCROLL_WAIT_MS = 2000
|
||||
_CARD_SELECTOR = 'div[jsname="y1Aese"]'
|
||||
|
||||
_EXTRACT_JS = """() => {
|
||||
const cards = document.querySelectorAll('div[jsname="y1Aese"]');
|
||||
return Array.from(cards).map(card => {
|
||||
const shareEl = card.closest('[data-share-url]');
|
||||
const shareUrl = shareEl ? shareEl.getAttribute('data-share-url') : '';
|
||||
|
||||
// Use shareUrl directly — it contains the full fragment (#fpstate=tldetail&htidocid=...)
|
||||
// needed to open the job detail panel in Google Jobs.
|
||||
let jobUrl = shareUrl;
|
||||
let htidocid = '';
|
||||
if (shareUrl) {
|
||||
const docMatch = shareUrl.match(/htidocid=([^&]+)/);
|
||||
if (docMatch) {
|
||||
htidocid = decodeURIComponent(docMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const dateSpan = card.querySelector('span[aria-label^="Posted"]');
|
||||
const salarySpan = card.querySelector('span[aria-label^="Salary"]');
|
||||
const typeSpan = card.querySelector('span[aria-label^="Employment Type"]');
|
||||
|
||||
// Positional extraction: filter aria-hidden and known UI texts, then take by index.
|
||||
// DOM order within a card is always: title → company → location.
|
||||
const UI_TEXTS = new Set(['Share', 'Click to copy link', 'Share link', 'Link copied']);
|
||||
const leafDivs = Array.from(card.querySelectorAll('div')).filter(d =>
|
||||
d.children.length === 0 &&
|
||||
d.getAttribute('aria-hidden') !== 'true' &&
|
||||
d.textContent.trim() &&
|
||||
!UI_TEXTS.has(d.textContent.trim())
|
||||
);
|
||||
const title = leafDivs[0]?.textContent.trim() || '';
|
||||
const company = leafDivs[1]?.textContent.trim() || '';
|
||||
// Location always contains " • via "; fallback to index 2
|
||||
const locationEl = leafDivs.find(d => d.textContent.includes(' \u2022 via ')) || leafDivs[2];
|
||||
const location = locationEl?.textContent.trim() || '';
|
||||
|
||||
return {
|
||||
title,
|
||||
company,
|
||||
location,
|
||||
url: jobUrl,
|
||||
external_job_id: htidocid,
|
||||
posted_text: dateSpan ? dateSpan.getAttribute('aria-label').replace('Posted ', '') : '',
|
||||
salary_text: salarySpan ? salarySpan.getAttribute('aria-label').replace('Salary ', '') : '',
|
||||
employment_type: typeSpan ? typeSpan.getAttribute('aria-label').replace('Employment Type ', '') : '',
|
||||
};
|
||||
});
|
||||
}"""
|
||||
|
||||
|
||||
@register("googlejobs")
|
||||
class GoogleJobsAdapter(ManifestDrivenAdapter):
|
||||
|
||||
def collect_cards(self, page, manifest) -> list[RawJobCard]:
|
||||
"""Scroll to load more results, then extract all cards via JS."""
|
||||
self._scroll_to_load(page)
|
||||
return self._extract_cards(page)
|
||||
|
||||
def paginate(self, page, page_index: int, config) -> bool:
|
||||
return False
|
||||
|
||||
def _scroll_to_load(self, page) -> None:
|
||||
for i in range(_MAX_SCROLLS):
|
||||
prev_count = page.evaluate(
|
||||
f"document.querySelectorAll('{_CARD_SELECTOR}').length"
|
||||
)
|
||||
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||
page.wait_for_timeout(_SCROLL_WAIT_MS)
|
||||
new_count = page.evaluate(
|
||||
f"document.querySelectorAll('{_CARD_SELECTOR}').length"
|
||||
)
|
||||
logger.debug(f"[googlejobs] Scroll {i+1}: {prev_count} → {new_count} cards")
|
||||
if new_count <= prev_count:
|
||||
logger.debug(f"[googlejobs] No new cards after scroll {i+1} — stopping")
|
||||
break
|
||||
|
||||
def _extract_cards(self, page) -> list[RawJobCard]:
|
||||
data = page.evaluate(_EXTRACT_JS)
|
||||
cards = []
|
||||
for item in data:
|
||||
title = (item.get("title") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
cards.append(RawJobCard(
|
||||
title=title,
|
||||
company=(item.get("company") or "").strip() or None,
|
||||
location=(item.get("location") or "").strip() or None,
|
||||
url=item.get("url") or None,
|
||||
external_job_id=(item.get("external_job_id") or "").strip() or None,
|
||||
posted_text=(item.get("posted_text") or "").strip() or None,
|
||||
salary_text=(item.get("salary_text") or "").strip() or None,
|
||||
employment_type=(item.get("employment_type") or "").strip() or None,
|
||||
))
|
||||
logger.debug(f"[googlejobs] Extracted {len(cards)} cards")
|
||||
return cards
|
||||
@ -0,0 +1,77 @@
|
||||
"""HospitalRecruiting adapter — JS extraction from job cards."""
|
||||
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
|
||||
|
||||
_BASE_URL = "https://www.hospitalrecruiting.com"
|
||||
|
||||
_EXTRACT_JS = """() => {
|
||||
const cards = document.querySelectorAll('div:has(> a[href^="/job/"][aria-label])');
|
||||
return Array.from(cards).map(card => {
|
||||
const link = card.querySelector('a[href^="/job/"][aria-label]');
|
||||
const h2 = card.querySelector('h2');
|
||||
const divs = Array.from(card.querySelectorAll(':scope > div'));
|
||||
const companyDiv = divs.find(d => d.textContent.includes('Company:'));
|
||||
const locationDiv = divs.find(d => !d.textContent.includes('Company:') && d.textContent.trim());
|
||||
const href = link ? link.getAttribute('href') : '';
|
||||
const idMatch = href.match(/\\/job\\/(\\d+)\\//);
|
||||
return {
|
||||
title: h2?.textContent?.trim() || link?.getAttribute('aria-label') || '',
|
||||
url: href,
|
||||
company: companyDiv ? companyDiv.textContent.replace('Company:', '').trim() : '',
|
||||
location: locationDiv?.textContent?.trim() || '',
|
||||
external_job_id: idMatch ? idMatch[1] : '',
|
||||
};
|
||||
});
|
||||
}"""
|
||||
|
||||
|
||||
@register("hospitalrecruiting")
|
||||
class HospitalRecruitingAdapter(ManifestDrivenAdapter):
|
||||
|
||||
def collect_cards(self, page, manifest) -> list[RawJobCard]:
|
||||
try:
|
||||
page.wait_for_selector('a[href^="/job/"][aria-label]', timeout=10000)
|
||||
except Exception:
|
||||
logger.debug("[hospitalrecruiting] job link wait timed out")
|
||||
return self._extract_cards(page)
|
||||
|
||||
def paginate(self, page, page_index: int, config) -> bool:
|
||||
return False
|
||||
|
||||
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
|
||||
# Make URL absolute
|
||||
if raw.url and raw.url.startswith("/"):
|
||||
raw = raw.model_copy(update={"url": _BASE_URL + raw.url})
|
||||
|
||||
# Strip "Specialty - " prefix from location
|
||||
# e.g. "Orthodontist - Roanoke, Virginia" → "Roanoke, Virginia"
|
||||
if raw.location:
|
||||
m = re.match(r'^[^,]+ - (.+)$', raw.location)
|
||||
if m:
|
||||
raw = raw.model_copy(update={"location": m.group(1)})
|
||||
|
||||
return super().normalize(raw)
|
||||
|
||||
def _extract_cards(self, page) -> list[RawJobCard]:
|
||||
data = page.evaluate(_EXTRACT_JS)
|
||||
cards = []
|
||||
for item in data:
|
||||
title = (item.get("title") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
cards.append(RawJobCard(
|
||||
title=title,
|
||||
company=(item.get("company") or "").strip() or None,
|
||||
location=(item.get("location") or "").strip() or None,
|
||||
url=item.get("url") or None,
|
||||
external_job_id=(item.get("external_job_id") or "").strip() or None,
|
||||
))
|
||||
logger.debug(f"[hospitalrecruiting] Extracted {len(cards)} cards")
|
||||
return cards
|
||||
@ -0,0 +1,41 @@
|
||||
site_id: googlejobs
|
||||
enabled: true
|
||||
repair_needed: false
|
||||
|
||||
identity:
|
||||
label: Google Jobs
|
||||
category: aggregator
|
||||
base_url: https://www.google.com
|
||||
start_url_template: "https://www.google.com/search?q=orthodontist+job&udm=8"
|
||||
|
||||
browser:
|
||||
profile_mode: persistent_chrome_profile
|
||||
profile_name: JobAgent
|
||||
headed_on_learn: true
|
||||
headless_on_run: true
|
||||
|
||||
search:
|
||||
keyword_mode: url_param
|
||||
result_list_wait_selector: "div[jsname='y1Aese']"
|
||||
|
||||
pagination:
|
||||
mode: none
|
||||
max_pages: 1
|
||||
|
||||
extract:
|
||||
container_selectors: []
|
||||
fields: {}
|
||||
|
||||
post_filters:
|
||||
include_title_keywords:
|
||||
- orthodontist
|
||||
|
||||
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,46 @@
|
||||
site_id: hospitalrecruiting
|
||||
enabled: true
|
||||
repair_needed: false
|
||||
|
||||
identity:
|
||||
label: HospitalRecruiting
|
||||
category: aggregator
|
||||
base_url: https://www.hospitalrecruiting.com
|
||||
start_url_template: "https://www.hospitalrecruiting.com/jobs/Orthodontist-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: "a[href^='/job/'][aria-label]"
|
||||
|
||||
pagination:
|
||||
mode: none
|
||||
max_pages: 1
|
||||
|
||||
extract:
|
||||
container_selectors: []
|
||||
fields: {}
|
||||
|
||||
post_filters:
|
||||
include_posted_text: []
|
||||
include_title_keywords:
|
||||
- orthodontist
|
||||
- orthodontic
|
||||
|
||||
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