Fix warnings, LinkedIn selector update, Telegram notify listing, usajobs title filter

- LinkedIn: new URL with geoId, updated selectors to li[data-occludable-job-id], scroll adapter, pagination disabled
- usajobs: add include_title_keywords post-filter (dentist/orthodontist/orthodontic)
- aroragroup: fix AJAX load via doloadJBSearchList(), fix container selector
- govtjobs/srpmic: clear result_list_wait_selector to avoid 15s timeout on 0-result pages
- orchestrator: wait selector timeout WARNING → DEBUG
- extractor: no-container WARNING → DEBUG
- notifier: provider-based dispatch (telegram/kakaotalk), build_listing_messages() for Telegram
- cli notify: send job listing with links instead of Ollama summary
- global.yaml: provider set to telegram

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent 22a0c6dbdc
commit 89ca7a4006

@ -16,11 +16,19 @@ class AroraGroupAdapter(ManifestDrivenAdapter):
_BASE_URL = "https://jobs.aroragroup.com" _BASE_URL = "https://jobs.aroragroup.com"
def collect_cards(self, page, manifest): def collect_cards(self, page, manifest):
"""Load initial results, click 'Load More' up to N times, then filter by keywords.""" """Trigger AJAX job load, click 'Load More' up to N times, then filter by keywords."""
# Trigger initial job list load via the page's JS function
try: try:
page.wait_for_selector(".job-post-row", timeout=15000, state="attached") page.evaluate("doloadJBSearchList(1)")
except Exception: except Exception:
logger.debug("[aroragroup] Initial results wait timed out") pass
# Wait for at least one title to be populated
try:
page.wait_for_selector(".POST_TITLE:not(:empty)", timeout=15000, state="attached")
except Exception:
logger.debug("[aroragroup] Job titles did not populate — no results or load failed")
return []
for i in range(_MAX_LOAD_MORE): for i in range(_MAX_LOAD_MORE):
btn = page.query_selector("button#loadMore") btn = page.query_selector("button#loadMore")
@ -28,12 +36,12 @@ class AroraGroupAdapter(ManifestDrivenAdapter):
logger.debug(f"[aroragroup] Load More button gone after {i} extra loads") logger.debug(f"[aroragroup] Load More button gone after {i} extra loads")
break break
current_count = len(page.query_selector_all(".job-post-row[onclick]")) current_count = len(page.query_selector_all(".POST_TITLE:not(:empty)"))
btn.click() btn.click()
try: try:
page.wait_for_function( page.wait_for_function(
f"document.querySelectorAll('.job-post-row[onclick]').length > {current_count}", f"document.querySelectorAll('.POST_TITLE:not(:empty)').length > {current_count}",
timeout=15000, timeout=15000,
) )
logger.debug(f"[aroragroup] Load More {i + 1}/{_MAX_LOAD_MORE}: loaded more cards") logger.debug(f"[aroragroup] Load More {i + 1}/{_MAX_LOAD_MORE}: loaded more cards")

@ -0,0 +1,34 @@
"""LinkedIn Jobs adapter — scroll to load all cards on first page only."""
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("linkedin")
class LinkedInAdapter(ManifestDrivenAdapter):
def collect_cards(self, page, manifest) -> list[RawJobCard]:
"""Scroll each card into view to trigger lazy-loading, then extract."""
self._scroll_to_load(page)
return super().collect_cards(page, manifest)
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
return super().normalize(raw)
@staticmethod
def _scroll_to_load(page) -> None:
"""Scroll each job card into view to force LinkedIn's virtual list to render."""
try:
page.evaluate("""() => {
const cards = Array.from(document.querySelectorAll('li[data-occludable-job-id]'));
cards.forEach(card => card.scrollIntoView({ behavior: 'instant', block: 'center' }));
}""")
page.wait_for_timeout(800)
page.evaluate("window.scrollTo(0, 0)")
page.wait_for_timeout(400)
except Exception as e:
logger.debug(f"[linkedin] scroll_to_load failed: {e}")

@ -5,6 +5,9 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import dotenv
dotenv.load_dotenv()
import typer import typer
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
@ -327,15 +330,16 @@ def test(
@app.command() @app.command()
def notify( def notify(
today: bool = typer.Option(True, "--today/--no-today", help="Re-send today's digest"), today: bool = typer.Option(True, "--today/--no-today", help="Re-send today's job listing"),
) -> None: ) -> None:
"""Re-send today's job summary via Telegram.""" """Send today's new job listings via Telegram (one message per site group)."""
from datetime import date from datetime import date
from gimme_job.config import load_global_config from gimme_job.config import load_global_config
from gimme_job.db.engine import get_engine, get_session_factory from gimme_job.db.engine import get_engine, get_session_factory
from gimme_job.db.repo import JobPostingRepo, SummaryRepo from gimme_job.db.repo import JobPostingRepo
from gimme_job.runtime.notifier import NotificationDispatcher from gimme_job.runtime.notifier import NotificationDispatcher, build_listing_messages
from gimme_job.runtime.telegram import TelegramClient
cfg = load_global_config() cfg = load_global_config()
engine = get_engine() engine = get_engine()
@ -343,31 +347,33 @@ def notify(
run_date = date.today() run_date = date.today()
with factory() as session: with factory() as session:
summary_repo = SummaryRepo()
summary = summary_repo.get_latest(session, run_date)
if summary:
text = summary.content
else:
# Build summary from today's postings
postings = JobPostingRepo().get_today_new(session, run_date) postings = JobPostingRepo().get_today_new(session, run_date)
if not postings: if not postings:
console.print("[yellow]No new postings found for today.[/yellow]") console.print("[yellow]No new postings found for today.[/yellow]")
raise typer.Exit(0) raise typer.Exit(0)
from gimme_job.runtime.summarizer import OllamaSummarizer
summarizer = OllamaSummarizer(
base_url=cfg.summarization.ollama_base_url,
model=cfg.summarization.model,
temperature=cfg.summarization.temperature,
)
text = summarizer.summarize(postings)
dispatcher = NotificationDispatcher(global_config=cfg, session_factory=factory) chunks = build_listing_messages(postings)
ok = dispatcher.send(text, run_date) console.print(f"Sending {len(chunks)} message(s) for {len(postings)} postings...")
if ok:
console.print("[green]✓[/green] Notification sent") client = TelegramClient()
sent = 0
if client.is_configured():
for chunk in chunks:
if client.send_message(chunk):
sent += 1
if sent == len(chunks):
console.print(f"[green]✓[/green] All {sent} message(s) sent")
else:
console.print(f"[yellow]![/yellow] Sent {sent}/{len(chunks)} messages")
else: else:
console.print("[yellow]![/yellow] Notification failed — saved as markdown fallback") console.print("[yellow]Telegram not configured[/yellow]")
# Always write markdown fallback
dispatcher = NotificationDispatcher(global_config=cfg, session_factory=factory)
full_text = "\n\n".join(chunks)
path = dispatcher.send_markdown_fallback(full_text, run_date)
console.print(f"Markdown saved: {path}")
# ── list ────────────────────────────────────────────────────────────────────── # ── list ──────────────────────────────────────────────────────────────────────

@ -53,6 +53,7 @@ class ExtractConfig(BaseModel):
class PostFilterConfig(BaseModel): class PostFilterConfig(BaseModel):
include_posted_text: list[str] = Field(default_factory=list) include_posted_text: list[str] = Field(default_factory=list)
include_title_keywords: list[str] = Field(default_factory=list)
class HealthcheckConfig(BaseModel): class HealthcheckConfig(BaseModel):

@ -60,7 +60,7 @@ def extract_cards_from_page(page: "Page", extract_config: ExtractConfig) -> list
continue continue
if not container_elements: if not container_elements:
logger.warning("No container elements found with any selector") logger.debug("No container elements found with any selector")
return cards return cards
for element in container_elements: for element in container_elements:

@ -1,12 +1,73 @@
"""Notification dispatcher: Telegram with Markdown fallback.""" """Notification dispatcher: Telegram or KakaoTalk with Markdown fallback."""
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
from typing import TYPE_CHECKING
from loguru import logger from loguru import logger
from gimme_job.config import GlobalConfig from gimme_job.config import GlobalConfig
if TYPE_CHECKING:
from gimme_job.models.db import JobPosting
_TELEGRAM_MAX_LEN = 4000 # leave room for safety margin
def build_listing_messages(postings: list["JobPosting"]) -> list[str]:
"""Format job postings as Telegram HTML chunks (≤4000 chars each)."""
from collections import defaultdict
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"
chunks: list[str] = []
current = header
for site_id, site_postings in sorted(by_site.items()):
site_block = f"\n<b>── {site_id.upper()} ({len(site_postings)}) ──</b>\n"
for p in site_postings:
# Title line
title = _escape_html(p.title)
line = f"• <b>{title}</b>"
# Details line
details = []
if p.company:
details.append(_escape_html(p.company))
if p.location:
details.append(_escape_html(p.location))
if p.employment_type:
details.append(_escape_html(p.employment_type))
if details:
line += f"\n {' | '.join(details)}"
# Link
if p.job_url:
line += f'\n <a href="{p.job_url}">링크</a>'
site_block += line + "\n"
# Flush chunk if adding this site would exceed limit
if len(current) + len(site_block) > _TELEGRAM_MAX_LEN:
chunks.append(current.rstrip())
current = site_block
else:
current += site_block
if current.strip():
chunks.append(current.rstrip())
return chunks or ["신규 채용 공고가 없습니다."]
def _escape_html(text: str) -> str:
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class NotificationDispatcher: class NotificationDispatcher:
def __init__(self, global_config: GlobalConfig, session_factory): def __init__(self, global_config: GlobalConfig, session_factory):
@ -15,11 +76,27 @@ class NotificationDispatcher:
def send(self, summary: str, run_date: date) -> bool: def send(self, summary: str, run_date: date) -> bool:
"""Send the summary. Returns True if any method succeeded.""" """Send the summary. Returns True if any method succeeded."""
from gimme_job.runtime.telegram import TelegramClient provider = self.cfg.notification.provider
client = TelegramClient()
success = False success = False
if provider == "kakaotalk":
from gimme_job.runtime.kakao import KakaoTalkClient
client = KakaoTalkClient()
if client.is_configured():
ok = client.send_self_memo(summary)
if ok:
success = True
self._log_notification(run_date, "kakaotalk", "success")
else:
logger.warning("KakaoTalk failed — writing markdown fallback")
self._log_notification(run_date, "kakaotalk", "failed", "send_self_memo returned False")
else:
logger.warning("KakaoTalk not configured — writing markdown fallback only")
self._log_notification(run_date, "kakaotalk", "skipped", "not configured")
else: # telegram (default)
from gimme_job.runtime.telegram import TelegramClient
client = TelegramClient()
if client.is_configured(): if client.is_configured():
ok = client.send_message(summary) ok = client.send_message(summary)
if ok: if ok:

@ -196,7 +196,7 @@ class RunOrchestrator:
state="attached", state="attached",
) )
except Exception: except Exception:
logger.warning(f"[{site_id}] Wait selector timed out — continuing anyway") logger.debug(f"[{site_id}] Wait selector timed out — continuing anyway")
# Check if site has existing data (for early stop on update runs) # Check if site has existing data (for early stop on update runs)
site_has_data = False site_has_data = False
@ -292,14 +292,24 @@ class RunOrchestrator:
# Apply post-filters # Apply post-filters
if manifest.post_filters.include_posted_text: if manifest.post_filters.include_posted_text:
from gimme_job.models.dto import RawJobCard
# Re-filter candidates based on posted_text
include_lower = [t.lower() for t in manifest.post_filters.include_posted_text] include_lower = [t.lower() for t in manifest.post_filters.include_posted_text]
candidates = [ candidates = [
c for c in candidates c for c in candidates
if not c.posted_text or any(t in (c.posted_text or "").lower() for t in include_lower) if not c.posted_text or any(t in (c.posted_text or "").lower() for t in include_lower)
] ]
if manifest.post_filters.include_title_keywords:
kws = [t.lower() for t in manifest.post_filters.include_title_keywords]
before = len(candidates)
candidates = [
c for c in candidates
if any(kw in (c.title or "").lower() for kw in kws)
]
logger.info(
f"[{site_id}] title keyword filter: {len(candidates)}/{before} kept "
f"(keywords: {manifest.post_filters.include_title_keywords})"
)
# Ensure fingerprints and deduplicate within batch # Ensure fingerprints and deduplicate within batch
candidates = ensure_fingerprints(candidates) candidates = ensure_fingerprints(candidates)
candidates = deduplicate_in_batch(candidates) candidates = deduplicate_in_batch(candidates)

@ -27,7 +27,7 @@ pagination:
extract: extract:
container_selectors: container_selectors:
- ".job-post-row[onclick]" - ".job-post-row"
fields: fields:
title: title:
text: text:

@ -25,5 +25,5 @@ summarization:
max_input_items: 200 max_input_items: 200
notification: notification:
provider: kakaotalk provider: telegram
fallback_markdown: true fallback_markdown: true

@ -19,7 +19,7 @@ search:
location_mode: none location_mode: none
sort_mode: none sort_mode: none
date_mode: none date_mode: none
result_list_wait_selector: "h3 a[href*='/jobs/']" result_list_wait_selector: ""
search_override: search_override:
keywords: keywords:

@ -6,7 +6,7 @@ identity:
label: LinkedIn Jobs label: LinkedIn Jobs
category: aggregator category: aggregator
base_url: https://www.linkedin.com base_url: https://www.linkedin.com
start_url_template: "https://www.linkedin.com/jobs/search/?keywords={keywords_urlencoded}&f_TPR=r86400&sortBy=DD" start_url_template: "https://www.linkedin.com/jobs/search/?keywords={keywords_urlencoded}&geoId=103644278&origin=JOB_SEARCH_PAGE_LOCATION_AUTOCOMPLETE"
browser: browser:
profile_mode: persistent_chrome_profile profile_mode: persistent_chrome_profile
@ -19,45 +19,28 @@ search:
location_mode: url_param location_mode: url_param
sort_mode: url_param sort_mode: url_param
date_mode: url_param date_mode: url_param
result_list_wait_selector: ".jobs-search__results-list li, .scaffold-layout__list-container li" result_list_wait_selector: "li[data-occludable-job-id]"
pagination: pagination:
mode: next_button mode: none
next_button_selectors: max_pages: 1
- "button[aria-label='View next page']"
- "button[aria-label*='next']"
- "li.artdeco-pagination__indicator--number.selected + li button"
max_pages: 3
extract: extract:
container_selectors: container_selectors:
- ".jobs-search__results-list li" - "li[data-occludable-job-id]"
- ".scaffold-layout__list-container li"
- "li:has(.base-card)"
fields: fields:
title: title:
text: text:
- "h3.base-search-card__title" - "a.job-card-list__title--link"
- ".job-card-list__title"
- "h3"
- "a[data-control-name='job_card_title']"
company: company:
text: text:
- "h4.base-search-card__subtitle" - ".artdeco-entity-lockup__subtitle span"
- ".job-card-container__primary-description"
- "a.job-card-container__company-name"
location: location:
text: text:
- "span.job-search-card__location" - ".job-card-container__metadata-wrapper li span"
- ".job-card-container__metadata-item"
posted_text:
text:
- "time"
- ".job-search-card__listdate"
- "span[class*='listdate']"
url: url:
attr: attr:
selector: "a.base-card__full-link, a[data-control-name='job_card_title'], a[href*='/jobs/view/']" selector: "a.job-card-list__title--link"
name: href name: href
post_filters: post_filters:

@ -19,7 +19,7 @@ search:
location_mode: none location_mode: none
sort_mode: none sort_mode: none
date_mode: none date_mode: none
result_list_wait_selector: "a[href*='/careers/srpmic/jobs/']" result_list_wait_selector: ""
search_override: search_override:
keywords: keywords:

@ -64,6 +64,10 @@ search_override:
post_filters: post_filters:
include_posted_text: [] include_posted_text: []
include_title_keywords:
- dentist
- orthodontist
- orthodontic
healthcheck: healthcheck:
min_items_expected: 0 min_items_expected: 0

Loading…
Cancel
Save