Compare commits

...

10 Commits

Author SHA1 Message Date
I Luk Kim ca5d229ca2 sites: focus active set on IHS/USAJOBS/tribal orthodontist roles
Disable all sites except ihs, usajobs, tribalhealth. Drop "dentist" from usajobs (search keywords + title filter) and tribalhealth, and add an orthodontist/orthodontic title filter to ihs, so the three active sites all return orthodontist roles only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago
I Luk Kim 7086dcb0ea tribalhealth: add adapter for tribal staffing job board
Tribal Health (tribalhealth.com) is a clinician staffing agency placing providers at tribal/IHS sites — the one non-subscribed source carrying net-new tribal dental roles not on ihs.gov/usajobs. Elementor WordPress loop; collect-all + keyword-filter (mirrors nativehealth). collect_cards walks all numbered board pages internally so sparse roles on later pages aren't dropped by the orchestrator's empty-page break; paginate() returns False.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 months ago
I Luk Kim e13a25b3d5 runtime: disable trace/screenshot/DOM snapshot saving
Tracing was accumulating 45GB+ of data (traces) + 5.6GB (dom) + 1.3GB
(screenshots) on every run. These artifacts are only useful during
development; remove them from the production run pipeline entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
I Luk Kim 37e5b59126 googlejobs: traverse <template> content to find apply URLs
Apply links (Indeed, Glassdoor, Monster, etc.) are pre-rendered inside
a <template> tag within each card's share_el. <template> content is
inert in the rendered DOM — a regular querySelectorAll from outside
returns nothing, which is why the previous fix produced empty URLs.

Iterate share_el's <template> elements and search their .content for
links matching utm_campaign=google_jobs_apply. Verified on the live
Google Jobs page that each card's template contains the correct
external apply URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
I Luk Kim fa748bebf9 googlejobs: filter out U.S. Navy company postings
Add exclude_company_keywords post-filter (case-insensitive substring
match against company field). Configure googlejobs.yaml to exclude
"U.S. Navy" / "US Navy" — these recurring military recruitment ads
aren't relevant orthodontist openings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
I Luk Kim 2f33ea890d googlejobs: link to external apply URL instead of Google deep-link
The htidocid-based Google Jobs deep-links require the job to be in the
current search context (location, session) to open the detail panel.
When clicked from Telegram in a different context, the link only opens
the search list page without the specific job detail.

Each card's share_el ancestor has external apply URLs (Indeed,
Glassdoor, Monster, BeBee, AAO Career Center, etc.) pre-loaded in the
DOM. These are direct, stable links to the source job posting that work
regardless of session or location.

Switch jobUrl to use the first external (non-google.com) apply URL
within the card's share_el.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 months ago
I Luk Kim a49a6539a1 googlejobs: use clean udm=8 deep-link format for job URLs
Previous shareUrl-based URLs included session-tied tokens (shmd, shmds,
shem) that can expire over time, causing the job detail panel to not
open when the link is clicked later from Telegram.

Switch to a minimal Google Jobs URL using only the htidocid and the new
SPA fragment format:
  https://www.google.com/search?q=<q>&udm=8#vhid=vt%3D20/docid%3D<id>&vssid=jobs-detail-viewer

Verified via Playwright: this format reliably opens the specific job
detail panel without any session-tied parameters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4 months ago
I Luk Kim 0835522d06 aaoinfo: fix pagination click on detached DOM element
Use page.click(selector) instead of ElementHandle.click() so Playwright
re-queries the element at click time after AJAX page refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4 months ago
I Luk Kim 5fa28b3948 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>
4 months ago
I Luk Kim 317f929059 LinkedIn: fix relative URLs and deduplicate accessibility text in titles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5 months ago

@ -5,6 +5,10 @@ KAKAO_ACCESS_TOKEN=
KAKAO_REFRESH_TOKEN=
KAKAO_USE_SELF_MEMO=true
# Telegram (comma-separated chat IDs for multiple recipients)
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# Ollama
OLLAMA_BASE_URL=http://127.0.0.1:11434

@ -12,14 +12,11 @@ from gimme_job.models.dto import JobPostingCandidate, RawJobCard
class AAOInfoAdapter(ManifestDrivenAdapter):
_BASE_URL = "https://careers.aaoinfo.org"
def _wait_for_results(self, page) -> None:
def collect_cards(self, page, manifest):
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:
@ -29,26 +26,49 @@ class AAOInfoAdapter(ManifestDrivenAdapter):
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"
)
# Capture current first job URL to detect when content actually changes
try:
first_tile = page.query_selector("div.job-tile .job-title a")
current_first_url = first_tile.get_attribute("href") if first_tile else None
except Exception:
current_first_url = None
# Determine which selector to use for the next page button
primary_sel = f"#page-item-{next_page} a"
fallback_sel = "ul.pagination li.page-item:not(.active):not(.disabled):last-child a"
if not next_btn:
if page.query_selector(primary_sel):
btn_sel = primary_sel
elif page.query_selector(fallback_sel):
btn_sel = fallback_sel
else:
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()
# Use page.click() (locator-based) so Playwright re-queries the element
# at click time, avoiding "not attached to DOM" errors after AJAX refresh.
page.click(btn_sel)
# Wait for the new page's active indicator to confirm AJAX completed
# Wait for page indicator to become active
try:
page.wait_for_selector(f"#page-item-{next_page}.active", timeout=12000)
except Exception:
page.wait_for_timeout(3000)
logger.debug(f"[aaoinfo] Page {next_page} did not become active — end of pagination")
return False
# Wait until the content actually changes (first job URL differs from before)
if current_first_url:
try:
page.wait_for_function(
f"""() => {{
const a = document.querySelector('div.job-tile .job-title a');
return a && a.getAttribute('href') !== {repr(current_first_url)};
}}""",
timeout=5000,
)
except Exception:
logger.debug(f"[aaoinfo] Content did not change after pagination to page {next_page}")
return True

@ -140,6 +140,7 @@ class ManifestDrivenAdapter:
company=company,
location=location,
url=url,
external_job_id=raw.external_job_id,
)
return JobPostingCandidate(

@ -0,0 +1,120 @@
"""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') : '';
let htidocid = '';
if (shareUrl) {
const docMatch = shareUrl.match(/htidocid=([^&]+)/);
if (docMatch) htidocid = decodeURIComponent(docMatch[1]);
}
// Apply URLs (Indeed, Glassdoor, Monster, etc.) live INSIDE a <template>
// tag within share_el. <template> content is inert in the rendered DOM
// querySelectorAll('a[...]') from outside doesn't see them. We must
// traverse template.content explicitly. These are stable direct links
// to the source posting that work regardless of session/location.
let jobUrl = '';
if (shareEl) {
const templates = shareEl.querySelectorAll('template');
for (const t of templates) {
const link = t.content.querySelector('a[href*="utm_campaign=google_jobs_apply"]');
if (link) {
jobUrl = link.getAttribute('href');
break;
}
}
}
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

@ -1,4 +1,4 @@
"""LinkedIn Jobs adapter — scroll to load all cards on first page only."""
"""LinkedIn Jobs adapter — scroll to load cards, paginate via start= URL param."""
from __future__ import annotations
from loguru import logger
@ -8,6 +8,30 @@ from gimme_job.adapters.registry import register
from gimme_job.models.dto import JobPostingCandidate, RawJobCard
def _deduplicate_title(title: str) -> str:
"""Remove LinkedIn's accessibility text duplication.
LinkedIn renders job titles as:
<span aria-hidden>Title</span>
<span class="accessible">Title with verification</span>
inner_text() returns both, so we get "Title Title with verification".
Strategy: if the title starts with a repeated prefix word-by-word, keep the shorter part.
"""
words = title.split()
n = len(words)
# Try splitting at each midpoint from 1 to n//2
for split in range(1, n // 2 + 1):
prefix = words[:split]
rest = words[split:]
# Exact full duplication: "Foo Bar Foo Bar"
if rest == prefix:
return " ".join(prefix)
# Prefix repeats at start of rest: "Foo Foo with verification"
if rest[:split] == prefix:
return " ".join(prefix)
return title
@register("linkedin")
class LinkedInAdapter(ManifestDrivenAdapter):
@ -17,21 +41,68 @@ class LinkedInAdapter(ManifestDrivenAdapter):
return super().collect_cards(page, manifest)
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
# Fix relative URLs → absolute
if raw.url and raw.url.startswith("/"):
raw = raw.model_copy(update={"url": "https://www.linkedin.com" + raw.url})
# Extract job ID from URL for stable fingerprinting (immune to text drift)
import re as _re
if raw.url and not raw.external_job_id:
m = _re.search(r"/jobs/view/(\d+)", raw.url)
if m:
raw = raw.model_copy(update={"external_job_id": m.group(1)})
# Fix duplicated titles caused by LinkedIn's accessibility span
# e.g. "Orthodontist Orthodontist with verification" → "Orthodontist"
# e.g. "LOCUM Dentist ... LOCUM Dentist ..." → "LOCUM Dentist ..."
if raw.title:
raw = raw.model_copy(update={"title": _deduplicate_title(raw.title)})
return super().normalize(raw)
def paginate(self, page, page_index: int, config) -> bool:
"""Navigate to next page by incrementing start= by 25."""
if page_index >= config.pagination.max_pages - 1:
return False
import re
start = (page_index + 1) * 25
current_url = page.url
# Replace existing start param or append it
if "start=" in current_url:
new_url = re.sub(r"start=\d+", f"start={start}", current_url)
else:
sep = "&" if "?" in current_url else "?"
new_url = current_url + sep + f"start={start}"
logger.info(f"[linkedin] Page {page_index + 2}: start={start}")
page.goto(new_url, timeout=30000)
try:
page.wait_for_selector(
"li[data-occludable-job-id]", timeout=15000, state="attached"
)
except Exception:
logger.debug("[linkedin] Wait selector timed out after pagination")
return True
@staticmethod
def _scroll_to_load(page) -> None:
"""Scroll each card into view one by one so LinkedIn renders all occluded items."""
"""Scroll cards into view one by one, re-checking count as LinkedIn lazy-loads more."""
try:
count = page.evaluate(
"document.querySelectorAll('li[data-occludable-job-id]').length"
)
for i in range(count):
scrolled = 0
max_cards = 30 # LinkedIn shows 25 per page; cap with margin
while scrolled < max_cards:
count = page.evaluate(
"document.querySelectorAll('li[data-occludable-job-id]').length"
)
if scrolled >= count:
break
page.evaluate(f"""() => {{
const cards = document.querySelectorAll('li[data-occludable-job-id]');
if (cards[{i}]) cards[{i}].scrollIntoView({{behavior: 'instant', block: 'center'}});
if (cards[{scrolled}]) cards[{scrolled}].scrollIntoView({{behavior: 'instant', block: 'center'}});
}}""")
page.wait_for_timeout(120)
logger.debug(f"[linkedin] Scrolled through {count} cards")
page.wait_for_timeout(200)
scrolled += 1
logger.debug(f"[linkedin] Scrolled through {scrolled} cards")
except Exception as e:
logger.debug(f"[linkedin] scroll_to_load failed: {e}")

@ -0,0 +1,125 @@
"""Tribal Health job board adapter — Elementor WordPress loop, numbered-page pagination.
Tribal Health (tribalhealth.com) is a clinician staffing agency that places providers
at tribal / IHS sites. Its job board renders an Elementor loop of EVERY open job
(heavily PT/OT/RN); it exposes no usable keyword search, so we collect every card and
keyword-filter to dentist/orthodontist roles here (mirrors the nativehealth pattern).
The dental roles are sparse and never guaranteed to land on page 1, so collect_cards
walks ALL board pages itself rather than relying on the orchestrator's page loop —
which stops paginating the moment a page yields zero (post-filter) cards. paginate()
therefore always returns False.
"""
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
_DEFAULT_KEYWORDS = ["orthodontist", "orthodontic", "dentist"]
_COMPANY = "Tribal Health"
_BOARD_URL = "https://tribalhealth.com/job-board/"
_MAX_SCROLLS = 4 # Elementor loop lazy-renders cards as you scroll
_CARD_JS = """() => {
const cards = Array.from(document.querySelectorAll('.e-loop-item'));
return cards.map(c => {
const cls = c.className || '';
const a = c.querySelector("a[href*='/jobs/']");
const h = c.querySelector('.elementor-heading-title');
// Term list items render as [specialty, "City, State"].
const terms = Array.from(
c.querySelectorAll('.elementor-post-info__terms-list-item')
).map(t => t.textContent.trim()).filter(Boolean);
const location = terms.find(t => t.includes(',')) || terms[terms.length - 1] || null;
const postId = (cls.match(/post-(\\d+)/) || [])[1] || null;
return {
title: h ? h.textContent.trim() : (a ? a.textContent.trim() : null),
location: location,
url: a ? a.href : null,
external_job_id: postId,
};
}).filter(j => j.title && j.url);
}"""
@register("tribalhealth")
class TribalHealthAdapter(ManifestDrivenAdapter):
def _keywords(self) -> list[str]:
kws = self.manifest.post_filters.include_title_keywords or _DEFAULT_KEYWORDS
return [k.lower() for k in kws]
def _scrape_current_page(self, page) -> list[dict]:
"""Wait for cards, scroll to force lazy render, return raw card dicts."""
try:
page.wait_for_selector(".e-loop-item", timeout=12000, state="attached")
except Exception:
return []
for _ in range(_MAX_SCROLLS):
try:
page.mouse.wheel(0, 4000)
page.wait_for_timeout(400)
except Exception:
break
try:
return page.evaluate(_CARD_JS) or []
except Exception as e:
logger.debug(f"[tribalhealth] card evaluate failed: {e}")
return []
def collect_cards(self, page, manifest) -> list[RawJobCard]:
"""Walk every board page, gather all cards, then keyword-filter by title."""
max_pages = max(1, manifest.pagination.max_pages)
raw_data: list[dict] = []
seen_urls: set[str] = set()
for page_num in range(1, max_pages + 1):
if page_num > 1:
# Page 1 was already navigated to by the orchestrator.
try:
page.goto(f"{_BOARD_URL}{page_num}/", timeout=30000, wait_until="domcontentloaded")
except Exception:
break
page_raw = self._scrape_current_page(page)
if not page_raw:
break # empty page = end of board
new_on_page = 0
for item in page_raw:
if item["url"] in seen_urls:
continue
seen_urls.add(item["url"])
raw_data.append(item)
new_on_page += 1
logger.debug(f"[tribalhealth] page {page_num}: {new_on_page} new raw cards")
if new_on_page == 0:
break # pagination wrapped around / no new cards
keywords = self._keywords()
cards: list[RawJobCard] = []
for item in raw_data:
title = item["title"]
if not any(kw in title.lower() for kw in keywords):
continue
cards.append(RawJobCard(
title=title,
company=_COMPANY,
location=item["location"] or None,
url=item["url"],
external_job_id=item["external_job_id"],
))
logger.info(
f"[tribalhealth] Keyword filter: {len(cards)}/{len(raw_data)} cards matched "
f"across {min(page_num, max_pages)} page(s)"
)
return cards
def paginate(self, page, page_index: int, config) -> bool:
"""All pages are walked inside collect_cards — no orchestrator-level paging."""
return False
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
return super().normalize(raw)

@ -191,15 +191,13 @@ def login() -> None:
# ── run ───────────────────────────────────────────────────────────────────────
@app.command()
def run(
site: Optional[str] = typer.Option(None, "--site", help="Run a single site only"),
dry_run: bool = typer.Option(False, "--dry-run", help="Extract but do not save or notify"),
skip_notify: bool = typer.Option(False, "--skip-notify", help="Skip Telegram notification"),
today_only: bool = typer.Option(False, "--today-only", help="Only process today's new items"),
limit_sites: Optional[int] = typer.Option(None, "--limit-sites", help="Max sites to run"),
def _execute_run(
site: Optional[str] = None,
dry_run: bool = False,
skip_notify: bool = False,
limit_sites: Optional[int] = None,
) -> None:
"""Collect job postings from all enabled sites."""
"""Core run logic shared by `run` and `auto`."""
from gimme_job.config import load_global_config
from gimme_job.db.engine import get_engine, get_session_factory
from gimme_job.runtime.orchestrator import RunOrchestrator
@ -216,7 +214,6 @@ def run(
limit_sites=limit_sites,
)
# Print summary table
table = Table(title="Run Summary", show_header=True)
table.add_column("Site", style="cyan")
table.add_column("Status")
@ -240,8 +237,56 @@ def run(
console.print(table)
console.print(f"\nTotal: [bold]{summary.total_found}[/bold] found, [bold green]{summary.total_new}[/bold green] new")
if summary.summary_text:
console.print("\n[bold]Summary sent.[/bold]")
@app.command()
def run(
site: Optional[str] = typer.Option(None, "--site", help="Run a single site only"),
dry_run: bool = typer.Option(False, "--dry-run", help="Extract but do not save or notify"),
skip_notify: bool = typer.Option(False, "--skip-notify", help="Skip Telegram notification"),
today_only: bool = typer.Option(False, "--today-only", help="Only process today's new items"),
limit_sites: Optional[int] = typer.Option(None, "--limit-sites", help="Max sites to run"),
) -> None:
"""Collect job postings from all enabled sites."""
_execute_run(site=site, dry_run=dry_run, skip_notify=skip_notify, limit_sites=limit_sites)
# ── auto ──────────────────────────────────────────────────────────────────────
@app.command()
def auto(
hour: float = typer.Option(1.0, "--hour", help="Interval in hours between each run (default: 1)"),
site: Optional[str] = typer.Option(None, "--site", help="Run a single site only"),
skip_notify: bool = typer.Option(False, "--skip-notify", help="Skip Telegram notification"),
) -> None:
"""Run automatically at a fixed interval (default: every 1 hour)."""
import time
from datetime import datetime, timedelta
interval_secs = int(hour * 3600)
run_count = 0
console.print(Panel(f"[bold cyan]gimme-job auto — every {hour}h[/bold cyan]", expand=False))
console.print(f"Interval: [bold]{hour}h[/bold]. Press Ctrl+C to stop.\n")
try:
while True:
run_count += 1
console.print(
f"[cyan]── Run #{run_count} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ──[/cyan]"
)
try:
_execute_run(site=site, skip_notify=skip_notify)
except Exception as e:
console.print(f"[red]Run #{run_count} failed: {e}[/red]")
next_run = datetime.now() + timedelta(seconds=interval_secs)
console.print(
f"\n[dim]Next run at {next_run.strftime('%H:%M:%S')}. Ctrl+C to stop.[/dim]"
)
time.sleep(interval_secs)
except KeyboardInterrupt:
console.print("\n[yellow]Auto mode stopped.[/yellow]")
# ── learn ─────────────────────────────────────────────────────────────────────

@ -70,14 +70,11 @@ class JobPostingRepo:
session.flush()
return len(candidates), new_count
def get_today_new(self, session: Session, run_date: date) -> list[JobPosting]:
return list(
session.execute(
select(JobPosting)
.where(JobPosting.run_date == run_date, JobPosting.is_new == True)
.order_by(JobPosting.site_id, JobPosting.first_seen_at)
).scalars().all()
)
def get_today_new(self, session: Session, run_date: date, since=None) -> list[JobPosting]:
q = select(JobPosting).where(JobPosting.run_date == run_date, JobPosting.is_new == True)
if since is not None:
q = q.where(JobPosting.first_seen_at >= since)
return list(session.execute(q.order_by(JobPosting.site_id, JobPosting.first_seen_at)).scalars().all())
def get_by_site(self, session: Session, site_id: str) -> list[JobPosting]:
return list(

@ -54,6 +54,8 @@ class ExtractConfig(BaseModel):
class PostFilterConfig(BaseModel):
include_posted_text: list[str] = Field(default_factory=list)
include_title_keywords: list[str] = Field(default_factory=list)
exclude_title_keywords: list[str] = Field(default_factory=list)
exclude_company_keywords: list[str] = Field(default_factory=list)
class HealthcheckConfig(BaseModel):

@ -17,10 +17,12 @@ class KakaoTalkClient:
def __init__(
self,
rest_api_key: Optional[str] = None,
client_secret: Optional[str] = None,
access_token: Optional[str] = None,
refresh_token: Optional[str] = None,
):
self.rest_api_key = rest_api_key or os.environ.get("KAKAO_REST_API_KEY", "")
self.client_secret = client_secret or os.environ.get("KAKAO_CLIENT_SECRET", "")
self.access_token = access_token or os.environ.get("KAKAO_ACCESS_TOKEN", "")
self.refresh_token = refresh_token or os.environ.get("KAKAO_REFRESH_TOKEN", "")
@ -93,15 +95,15 @@ class KakaoTalkClient:
def refresh_access_token(self) -> Optional[str]:
"""Refresh the access token using the refresh token."""
import httpx
response = httpx.post(
KAKAO_TOKEN_URL,
data={
"grant_type": "refresh_token",
"client_id": self.rest_api_key,
"refresh_token": self.refresh_token,
},
timeout=15.0,
)
data = {
"grant_type": "refresh_token",
"client_id": self.rest_api_key,
"refresh_token": self.refresh_token,
}
if self.client_secret:
data["client_secret"] = self.client_secret
response = httpx.post(KAKAO_TOKEN_URL, data=data, timeout=15.0)
response.raise_for_status()
data = response.json()
new_token = data.get("access_token")

@ -14,16 +14,28 @@ if TYPE_CHECKING:
_TELEGRAM_MAX_LEN = 4000 # leave room for safety margin
def build_listing_messages(postings: list["JobPosting"]) -> list[str]:
def build_listing_messages(postings: list["JobPosting"], run_started_at=None) -> list[str]:
"""Format job postings as Telegram HTML chunks (≤4000 chars each)."""
from collections import defaultdict
from datetime import timezone
by_site: dict[str, list] = defaultdict(list)
for p in postings:
by_site[p.site_id].append(p)
total = len(postings)
header = f"📋 <b>오늘의 신규 채용 공고 ({total}개)</b>\n"
if run_started_at is not None:
# Convert UTC to local time for display
try:
import zoneinfo
tz = zoneinfo.ZoneInfo("America/Phoenix")
local_dt = run_started_at.replace(tzinfo=timezone.utc).astimezone(tz)
dt_str = local_dt.strftime("%Y-%m-%d %H:%M")
except Exception:
dt_str = run_started_at.strftime("%Y-%m-%d %H:%M")
header = f"📋 <b>신규 채용 공고 {total}개</b> <i>{dt_str}</i>\n"
else:
header = f"📋 <b>신규 채용 공고 {total}개</b>\n"
chunks: list[str] = []
current = header

@ -74,7 +74,7 @@ class RunOrchestrator:
# Summarize and notify
if not dry_run and not skip_notify and summary.total_new > 0:
self._summarize_and_notify(summary, run_date)
self._summarize_and_notify(summary, run_date, run_started_at=summary.run_date)
return summary
@ -95,8 +95,6 @@ class RunOrchestrator:
query = merge_query(self.cfg, manifest)
run_id = str(uuid.uuid4())[:8]
from gimme_job.utils.paths import screenshot_path, dom_snapshot_path, trace_path
try:
if manifest.search_override.multi_keyword_mode == "separate" and query.keywords:
# Run one scrape per keyword and merge results
@ -153,7 +151,6 @@ class RunOrchestrator:
from gimme_job.runtime.browser import BrowserManager
from gimme_job.runtime.dedupe import deduplicate_in_batch, ensure_fingerprints
from gimme_job.runtime.extractor import apply_post_filters
from gimme_job.utils.paths import dom_snapshot_path, screenshot_path, trace_path
headless = manifest.browser.headless_on_run
profile = manifest.browser.profile_name or self.cfg.runtime.profile_name
@ -167,7 +164,6 @@ class RunOrchestrator:
try:
context = bm.open_context()
bm.start_tracing()
page = bm.new_page()
# Set timeouts
@ -214,6 +210,7 @@ class RunOrchestrator:
manifest.pagination.max_pages,
self.cfg.runtime.max_pages_per_site,
)
consecutive_all_known = 0
for page_idx in range(max_pages):
page_cards = adapter.collect_cards(page, manifest)
all_cards.extend(page_cards)
@ -223,7 +220,8 @@ class RunOrchestrator:
break # no results on this page — stop paginating
# Early stop: if site has existing data and this page's cards are all known,
# we've caught up to previously stored content — no need to paginate further.
# increment counter. Stop only after 2 consecutive all-known pages to avoid
# missing new items that appear after a block of already-seen pages.
if site_has_data and page_idx >= 1:
try:
from gimme_job.db.engine import db_session
@ -240,11 +238,16 @@ class RunOrchestrator:
with db_session() as _s:
known = JobPostingRepo().get_existing_fingerprints(_s, page_fps)
if len(known) >= len(page_fps):
consecutive_all_known += 1
logger.info(
f"[{site_id}] Page {page_idx+1}: all {len(page_fps)} items"
" already in DB — stopping early"
f" already in DB (consecutive: {consecutive_all_known})"
)
break
if consecutive_all_known >= 2:
logger.info(f"[{site_id}] 2 consecutive all-known pages — stopping early")
break
else:
consecutive_all_known = 0
except Exception as e:
logger.debug(f"[{site_id}] Early stop check failed: {e}")
@ -253,30 +256,7 @@ class RunOrchestrator:
break
time.sleep(0.5)
# Take screenshot
try:
ss_path = screenshot_path(site_id, run_id)
page.screenshot(path=str(ss_path), full_page=False)
result.screenshot_path = ss_path
except Exception as e:
logger.debug(f"[{site_id}] Screenshot failed: {e}")
# Save DOM snapshot
try:
dom_path = dom_snapshot_path(site_id, run_id)
dom_path.parent.mkdir(parents=True, exist_ok=True)
dom_path.write_text(page.content(), encoding="utf-8")
result.dom_snapshot_path = dom_path
except Exception as e:
logger.debug(f"[{site_id}] DOM snapshot failed: {e}")
finally:
try:
t_path = trace_path(site_id, run_id)
bm.save_trace(t_path)
result.trace_path = t_path
except Exception:
pass
bm.close()
# Normalize cards
@ -310,13 +290,37 @@ class RunOrchestrator:
f"(keywords: {manifest.post_filters.include_title_keywords})"
)
if manifest.post_filters.exclude_title_keywords:
exc_kws = [t.lower() for t in manifest.post_filters.exclude_title_keywords]
before = len(candidates)
candidates = [
c for c in candidates
if not any(kw in (c.title or "").lower() for kw in exc_kws)
]
logger.info(
f"[{site_id}] title exclude filter: {len(candidates)}/{before} kept "
f"(excluded: {manifest.post_filters.exclude_title_keywords})"
)
if manifest.post_filters.exclude_company_keywords:
exc_co = [t.lower() for t in manifest.post_filters.exclude_company_keywords]
before = len(candidates)
candidates = [
c for c in candidates
if not any(kw in (c.company or "").lower() for kw in exc_co)
]
logger.info(
f"[{site_id}] company exclude filter: {len(candidates)}/{before} kept "
f"(excluded: {manifest.post_filters.exclude_company_keywords})"
)
# Ensure fingerprints and deduplicate within batch
candidates = ensure_fingerprints(candidates)
candidates = deduplicate_in_batch(candidates)
return candidates
def _summarize_and_notify(self, summary: RunSummary, run_date: date) -> None:
def _summarize_and_notify(self, summary: RunSummary, run_date: date, run_started_at=None) -> None:
try:
from gimme_job.db.engine import db_session
from gimme_job.db.repo import JobPostingRepo
@ -324,15 +328,18 @@ class RunOrchestrator:
from gimme_job.runtime.telegram import TelegramClient
with db_session() as session:
postings = JobPostingRepo().get_today_new(session, run_date)
postings = JobPostingRepo().get_today_new(session, run_date, since=run_started_at)
if not postings:
return
chunks = build_listing_messages(postings)
chunks = build_listing_messages(postings, run_started_at=run_started_at)
client = TelegramClient()
if client.is_configured():
for chunk in chunks:
import time as _time
for i, chunk in enumerate(chunks):
client.send_message(chunk)
if i < len(chunks) - 1:
_time.sleep(1) # avoid Telegram rate limiting between chunks
dispatcher = NotificationDispatcher(
global_config=self.cfg, session_factory=self.session_factory

@ -2,12 +2,14 @@
from __future__ import annotations
import os
import time
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
_RETRY_DELAYS = (5, 10) # seconds between retries (2 retries = 3 total attempts)
class TelegramClient:
@ -17,13 +19,15 @@ class TelegramClient:
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", "")
raw_ids = chat_id or os.environ.get("TELEGRAM_CHAT_ID", "")
# Support comma-separated multiple chat IDs: "123456789,987654321"
self.chat_ids: list[str] = [cid.strip() for cid in raw_ids.split(",") if cid.strip()]
def is_configured(self) -> bool:
return bool(self.bot_token and self.chat_id)
return bool(self.bot_token and self.chat_ids)
def send_message(self, text: str) -> bool:
"""Send a message to the configured chat. Returns True on success."""
"""Send a message to all configured chats. Returns True if all succeeded."""
if not self.is_configured():
logger.warning("Telegram not configured (missing BOT_TOKEN or CHAT_ID)")
return False
@ -31,23 +35,38 @@ class TelegramClient:
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
import httpx
all_ok = True
for chat_id in self.chat_ids:
ok = self._send_to(httpx, chat_id, text)
if not ok:
all_ok = False
return all_ok
def _send_to(self, httpx, chat_id: str, text: str) -> bool:
"""Send to a single chat ID with retry. Returns True on success."""
for attempt, delay in enumerate([0] + list(_RETRY_DELAYS), start=1):
if delay:
logger.debug(f"Telegram retry {attempt} for {chat_id} in {delay}s...")
time.sleep(delay)
try:
response = httpx.post(
TELEGRAM_API_URL.format(token=self.bot_token),
json={
"chat_id": chat_id,
"text": text,
"parse_mode": "HTML",
},
timeout=30.0,
)
if response.status_code == 200 and response.json().get("ok"):
logger.info(f"Telegram message sent to {chat_id}")
return True
else:
logger.error(f"Telegram API error for {chat_id}: {response.text[:200]}")
return False # API errors won't be fixed by retrying
except Exception as e:
logger.warning(f"Telegram send attempt {attempt} to {chat_id} failed: {e}")
logger.error(f"Telegram send to {chat_id} failed after all retries")
return False

@ -17,21 +17,25 @@ def compute_fingerprint(
company: str | None,
location: str | None,
url: str | None,
external_job_id: str | None = None,
) -> str:
"""Compute a SHA256 fingerprint for deduplication.
URL is canonical-ized (query params stripped) if present.
Falls back to title+company+location when URL is absent.
When external_job_id is provided (e.g. LinkedIn job ID, Google htidocid),
use site_id + external_job_id only immune to title/location text drift.
Otherwise fall back to title+company+location+canonical_url.
"""
canonical_url = _canonical_url(url) if url else ""
parts = [
normalize_for_fingerprint(site_id),
normalize_for_fingerprint(title),
normalize_for_fingerprint(company),
normalize_for_fingerprint(location),
canonical_url,
]
if external_job_id:
parts = [normalize_for_fingerprint(site_id), external_job_id.strip()]
else:
canonical_url = _canonical_url(url) if url else ""
parts = [
normalize_for_fingerprint(site_id),
normalize_for_fingerprint(title),
normalize_for_fingerprint(company),
normalize_for_fingerprint(location),
canonical_url,
]
raw = "|".join(parts)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()

@ -1,12 +1,12 @@
site_id: aaoinfo
enabled: true
enabled: false
repair_needed: false
identity:
label: AAO Career Center
category: aggregator
base_url: https://careers.aaoinfo.org
start_url_template: "https://careers.aaoinfo.org/jobs/"
start_url_template: "https://careers.aaoinfo.org/jobs/view/associate-orthodontist/?sort=start_"
browser:
profile_mode: persistent_chrome_profile
@ -27,7 +27,7 @@ pagination:
extract:
container_selectors:
- "div.job-tile:not(.job-mo)"
- "div.job-tile:not(.job-mo):not(:has(.label-preferred))"
fields:
title:
text:

@ -1,5 +1,5 @@
site_id: aroragroup
enabled: true
enabled: false
repair_needed: false
identity:

@ -1,5 +1,5 @@
site_id: bfrench
enabled: true
enabled: false
repair_needed: false
identity:

@ -1,5 +1,5 @@
site_id: docshealth
enabled: true
enabled: false
repair_needed: false
identity:
@ -52,6 +52,8 @@ extract:
post_filters:
include_posted_text: []
include_title_keywords:
- orthodontist
healthcheck:
min_items_expected: 0

@ -1,5 +1,5 @@
site_id: gilariver
enabled: true
enabled: false
repair_needed: false
identity:

@ -0,0 +1,44 @@
site_id: googlejobs
enabled: false
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
exclude_company_keywords:
- u.s. navy
- us navy
healthcheck:
min_items_expected: 0
required_fields:
- title
fail_if_zero_when_known_active: false
learn:
last_learned_at: null
source_url: null

@ -1,5 +1,5 @@
site_id: govtjobs
enabled: true
enabled: false
repair_needed: false
identity:

@ -1,5 +1,5 @@
site_id: hospitaljobsonline
enabled: true
enabled: false
repair_needed: false
identity:

@ -0,0 +1,46 @@
site_id: hospitalrecruiting
enabled: false
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

@ -1,5 +1,5 @@
site_id: hrsa
enabled: true
enabled: false
repair_needed: false
identity:
@ -23,7 +23,10 @@ search:
search_override:
keywords:
- orthodontist
- orthodontic
- dentist
multi_keyword_mode: separate
pagination:
mode: none
@ -38,6 +41,10 @@ extract:
post_filters:
include_posted_text: []
include_title_keywords:
- orthodontist
- orthodontic
- dentist
healthcheck:
min_items_expected: 0

@ -39,6 +39,9 @@ extract:
post_filters:
include_posted_text: []
include_title_keywords:
- orthodontist
- orthodontic
healthcheck:
min_items_expected: 0

@ -1,12 +1,12 @@
site_id: linkedin
enabled: true
enabled: false
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}&geoId=103644278&origin=JOB_SEARCH_PAGE_LOCATION_AUTOCOMPLETE"
start_url_template: "https://www.linkedin.com/jobs/search/?keywords=%22orthodontist%22&geoId=103644278&origin=JOB_SEARCH_PAGE_LOCATION_AUTOCOMPLETE"
browser:
profile_mode: persistent_chrome_profile
@ -22,8 +22,8 @@ search:
result_list_wait_selector: "li[data-occludable-job-id]"
pagination:
mode: none
max_pages: 1
mode: url_increment
max_pages: 3
extract:
container_selectors:
@ -44,7 +44,10 @@ extract:
name: href
post_filters:
include_posted_text: []
include_title_keywords:
- orthodontist
exclude_title_keywords:
- volunteer
healthcheck:
min_items_expected: 0

@ -1,5 +1,5 @@
site_id: nativehealth
enabled: true
enabled: false
repair_needed: false
identity:

@ -1,5 +1,5 @@
site_id: pdshealth
enabled: true
enabled: false
repair_needed: false
identity:
@ -45,6 +45,8 @@ extract:
post_filters:
include_posted_text: []
include_title_keywords:
- orthodontist
healthcheck:
min_items_expected: 0

@ -1,5 +1,5 @@
site_id: saltdental
enabled: true
enabled: false
repair_needed: false
identity:
@ -45,6 +45,8 @@ extract:
post_filters:
include_posted_text: []
include_title_keywords:
- orthodontist
healthcheck:
min_items_expected: 0

@ -1,5 +1,5 @@
site_id: southernortho
enabled: true
enabled: false
repair_needed: false
identity:
@ -45,6 +45,8 @@ extract:
post_filters:
include_posted_text: []
include_title_keywords:
- orthodontist
healthcheck:
min_items_expected: 0

@ -1,5 +1,5 @@
site_id: srpmic
enabled: true
enabled: false
repair_needed: false
identity:

@ -0,0 +1,64 @@
site_id: tribalhealth
enabled: true
repair_needed: false
identity:
label: Tribal Health Job Board
category: staffing_agency
base_url: https://tribalhealth.com
start_url_template: "https://tribalhealth.com/job-board/"
browser:
profile_mode: persistent_chrome_profile
profile_name: JobAgent
headed_on_learn: true
headless_on_run: true
# The board is NOT keyword-searchable — it renders an Elementor loop of ALL open
# jobs (PT/OT/RN-heavy clinician staffing). We collect every card and keyword-filter
# to dental/ortho roles in the adapter + post_filters. Do NOT set
# multi_keyword_mode: separate (there is no per-keyword search to sweep).
search:
keyword_mode: none
location_mode: none
sort_mode: none
date_mode: none
result_list_wait_selector: ".e-loop-item"
pagination:
mode: url_increment # /job-board/{N}/ — walked inside TribalHealthAdapter.collect_cards
max_pages: 10 # board is ~3 pages today; headroom so page-4+ roles aren't silently dropped.
# collect_cards stops at the first empty page (dedup-by-URL guard prevents loops).
# NOTE: extraction is fully handled by TribalHealthAdapter.collect_cards (JS over .e-loop-item);
# this block is documentary only — the registry always returns the custom adapter, so these
# selectors are never executed by the manifest-driven path.
extract:
container_selectors:
- ".e-loop-item"
fields:
title:
text:
- ".elementor-heading-title"
url:
attr:
selector: "a[href*='/jobs/']"
name: "href"
location:
text:
- ".elementor-post-info__terms-list-item" # adapter picks the term containing a comma ("City, State")
post_filters:
include_title_keywords:
- orthodontist
- orthodontic
healthcheck:
min_items_expected: 0
required_fields:
- title
fail_if_zero_when_known_active: false
learn:
last_learned_at: 2026-06-12T00:00:00
source_url: "https://tribalhealth.com/job-board/"

@ -57,7 +57,6 @@ extract:
search_override:
keywords:
- dentist
- orthodontics
- orthodontist
multi_keyword_mode: separate
@ -65,7 +64,6 @@ search_override:
post_filters:
include_posted_text: []
include_title_keywords:
- dentist
- orthodontist
- orthodontic

@ -0,0 +1,81 @@
"""Smoke test for the Tribal Health job board adapter.
Verifies that:
1. The manifest parses and exposes required fields
2. The adapter is registered
3. (network) The board opens and either dental/ortho cards are detected OR a
zero-result state is handled cleanly; any returned card matches the keyword filter
NOTE: The network test requires a connection and the Chrome browser.
Mark with @pytest.mark.network to allow skipping in CI.
"""
import pytest
@pytest.fixture
def tribalhealth_manifest():
from gimme_job.config import load_site_manifest
try:
return load_site_manifest("tribalhealth")
except FileNotFoundError:
pytest.skip("tribalhealth.yaml not found")
def test_tribalhealth_manifest_valid(tribalhealth_manifest):
"""Manifest parses and has required fields."""
assert tribalhealth_manifest.site_id == "tribalhealth"
assert tribalhealth_manifest.identity.base_url
assert tribalhealth_manifest.identity.start_url_template
assert tribalhealth_manifest.extract.container_selectors
# Keyword filter must be present — the board is not keyword-searchable.
assert tribalhealth_manifest.post_filters.include_title_keywords
def test_tribalhealth_adapter_registered():
"""TribalHealthAdapter is registered in the registry."""
from gimme_job.adapters.registry import _load_all_adapters, _REGISTRY
_load_all_adapters()
assert "tribalhealth" in _REGISTRY
def test_tribalhealth_start_url(tribalhealth_manifest):
"""Start URL points at the job board."""
from gimme_job.models.dto import SearchQuery
url = tribalhealth_manifest.build_start_url(SearchQuery())
assert "tribalhealth.com/job-board" in url
@pytest.mark.network
def test_tribalhealth_smoke(tribalhealth_manifest):
"""Open the board and verify cards (keyword-matched) or a clean zero-result."""
from gimme_job.adapters.tribalhealth import TribalHealthAdapter
from gimme_job.runtime.browser import BrowserManager
adapter = TribalHealthAdapter(tribalhealth_manifest)
bm = BrowserManager(headless=True)
try:
bm.open_context(headless=True)
page = bm.new_page()
page.set_default_timeout(20000)
url = tribalhealth_manifest.build_start_url(
__import__("gimme_job.models.dto", fromlist=["SearchQuery"]).SearchQuery()
)
page.goto(url, timeout=45000, wait_until="domcontentloaded")
cards = adapter.collect_cards(page, tribalhealth_manifest)
assert isinstance(cards, list), "collect_cards must return a list"
keywords = [k.lower() for k in tribalhealth_manifest.post_filters.include_title_keywords]
for card in cards:
assert card.title and card.title.strip(), "Card must have a title"
# Every returned card must satisfy the keyword filter.
assert any(kw in card.title.lower() for kw in keywords), (
f"Card slipped the keyword filter: {card.title!r}"
)
# At least one field beyond title.
assert any([card.location, card.url, card.company]), "Card needs a second field"
finally:
bm.close()
Loading…
Cancel
Save