Spaces:
Sleeping
Sleeping
feat: upgrade scraper - multi keyword, --once mode, GitHub Actions workflow
Browse files- .env.example +1 -1
- .github/workflows/scrape.yml +28 -0
- base.py +19 -1
- cleaner.py +3 -0
- extract.py +47 -0
- jobstreet.py +131 -97
- linkedin.py +112 -87
- normalize.py +2 -0
- readme.md +60 -13
- requirements.txt +2 -2
- run.py +54 -21
- skills_keyword.py +35 -4
.env.example
CHANGED
|
@@ -1,2 +1,2 @@
|
|
| 1 |
SUPABASE_URL=your_supabase_url_here
|
| 2 |
-
|
|
|
|
| 1 |
SUPABASE_URL=your_supabase_url_here
|
| 2 |
+
SUPABASE_ANON_KEY=your_supabase_anon_key_here
|
.github/workflows/scrape.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: scrape
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
schedule:
|
| 5 |
+
- cron: "0 3 * * *"
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
run:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- uses: actions/checkout@v4
|
| 13 |
+
|
| 14 |
+
- uses: actions/setup-python@v5
|
| 15 |
+
with:
|
| 16 |
+
python-version: "3.12"
|
| 17 |
+
|
| 18 |
+
- name: Install dependencies
|
| 19 |
+
run: pip install -r requirements.txt
|
| 20 |
+
|
| 21 |
+
- name: Install Playwright Chromium
|
| 22 |
+
run: python -m playwright install --with-deps chromium
|
| 23 |
+
|
| 24 |
+
- name: Run scraper
|
| 25 |
+
run: python run.py --once
|
| 26 |
+
env:
|
| 27 |
+
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
| 28 |
+
SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
|
base.py
CHANGED
|
@@ -1,11 +1,29 @@
|
|
| 1 |
import random
|
| 2 |
import time
|
| 3 |
|
|
|
|
| 4 |
def human_delay(min_sec=2, max_sec=6):
|
| 5 |
time.sleep(random.uniform(min_sec, max_sec))
|
| 6 |
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
def jitter_scroll(page):
|
| 9 |
page.evaluate("""
|
| 10 |
window.scrollTo(0, document.body.scrollHeight / 2);
|
| 11 |
-
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import random
|
| 2 |
import time
|
| 3 |
|
| 4 |
+
|
| 5 |
def human_delay(min_sec=2, max_sec=6):
|
| 6 |
time.sleep(random.uniform(min_sec, max_sec))
|
| 7 |
|
| 8 |
|
| 9 |
+
def short_delay(min_sec=1, max_sec=2):
|
| 10 |
+
time.sleep(random.uniform(min_sec, max_sec))
|
| 11 |
+
|
| 12 |
+
|
| 13 |
def jitter_scroll(page):
|
| 14 |
page.evaluate("""
|
| 15 |
window.scrollTo(0, document.body.scrollHeight / 2);
|
| 16 |
+
""")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def goto_with_retry(page, url, timeout=45000, attempts=3):
|
| 20 |
+
for i in range(attempts):
|
| 21 |
+
try:
|
| 22 |
+
page.goto(url, wait_until="commit", timeout=timeout)
|
| 23 |
+
return True
|
| 24 |
+
except Exception as e:
|
| 25 |
+
if "ERR_NAME_NOT_RESOLVED" in str(e) or "ERR_CONNECTION" in str(e):
|
| 26 |
+
time.sleep(2 * (i + 1))
|
| 27 |
+
continue
|
| 28 |
+
raise
|
| 29 |
+
return False
|
cleaner.py
CHANGED
|
@@ -23,4 +23,7 @@ def clean_job(job):
|
|
| 23 |
"location": (job.get("location") or "").strip() or "Indonesia",
|
| 24 |
"description": (job.get("description") or "").strip(),
|
| 25 |
"skills": job.get("skills", []),
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
|
|
|
| 23 |
"location": (job.get("location") or "").strip() or "Indonesia",
|
| 24 |
"description": (job.get("description") or "").strip(),
|
| 25 |
"skills": job.get("skills", []),
|
| 26 |
+
"type": job.get("type", "Full-time"),
|
| 27 |
+
"salary": (job.get("salary") or "").strip() or None,
|
| 28 |
+
"contact_info": (job.get("contact_info") or "").strip() or None,
|
| 29 |
}
|
extract.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def extract_salary(text):
|
| 5 |
+
if not text:
|
| 6 |
+
return None
|
| 7 |
+
|
| 8 |
+
patterns = [
|
| 9 |
+
r"Rp\s?[\d.,]+\s?(rb|ribu|juta|jt|miliar|m)?",
|
| 10 |
+
r"\$\s?[\d.,]+\s?(k|m)?",
|
| 11 |
+
r"IDR\s?[\d.,]+",
|
| 12 |
+
r"[\d.,]+\s?(juta|jt|rb|ribu|miliar)\s?(per|/)?\s?(bulan|tahun|month|year)?",
|
| 13 |
+
]
|
| 14 |
+
for pattern in patterns:
|
| 15 |
+
match = re.search(pattern, text, re.IGNORECASE)
|
| 16 |
+
if match:
|
| 17 |
+
return match.group(0).strip()
|
| 18 |
+
return None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def extract_job_type(text):
|
| 22 |
+
if not text:
|
| 23 |
+
return "Full-time"
|
| 24 |
+
|
| 25 |
+
lowered = text.lower()
|
| 26 |
+
if re.search(r"\bintern(ship)?\b", lowered):
|
| 27 |
+
return "Internship"
|
| 28 |
+
if re.search(r"\bpart[- ]?time\b", lowered):
|
| 29 |
+
return "Part-time"
|
| 30 |
+
if re.search(r"\bcontract\b|\bfreelance\b", lowered):
|
| 31 |
+
return "Contract"
|
| 32 |
+
return "Full-time"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def extract_contact(text):
|
| 36 |
+
if not text:
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
email = re.search(r"[\w.+-]+@[\w-]+\.[\w.-]+", text)
|
| 40 |
+
if email:
|
| 41 |
+
return email.group(0)
|
| 42 |
+
|
| 43 |
+
phone = re.search(r"(?:\+62|0)\s?8[\d\s-]{8,12}", text)
|
| 44 |
+
if phone:
|
| 45 |
+
return phone.group(0).strip()
|
| 46 |
+
|
| 47 |
+
return None
|
jobstreet.py
CHANGED
|
@@ -1,122 +1,156 @@
|
|
|
|
|
|
|
|
| 1 |
from bs4 import BeautifulSoup
|
| 2 |
-
from browser import get_browser
|
| 3 |
-
from base import human_delay, jitter_scroll
|
| 4 |
from urllib.parse import urljoin
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from skills_keyword import extract_skills
|
| 6 |
|
|
|
|
| 7 |
|
| 8 |
-
def scrape_jobstreet(keyword="developer"):
|
| 9 |
-
p, browser, context = get_browser()
|
| 10 |
-
page = context.new_page()
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
|
|
|
| 14 |
|
| 15 |
try:
|
| 16 |
-
page
|
| 17 |
-
except Exception:
|
| 18 |
-
page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
cards = soup.select("article[data-testid='job-card'], article[data-automation='job-card'], article")
|
| 30 |
|
| 31 |
-
for card in cards:
|
| 32 |
try:
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
"h1[data-automation='job-detail-title'], "
|
| 37 |
-
"a[data-testid='job-title'], "
|
| 38 |
-
"h3 a, h2 a, a[href*='/job/']"
|
| 39 |
-
)
|
| 40 |
-
# Company name
|
| 41 |
-
company_el = card.select_one(
|
| 42 |
-
"span[data-automation='jobCompany'], "
|
| 43 |
-
"a[data-automation='jobCompany'], "
|
| 44 |
-
"[data-testid='company-name'], "
|
| 45 |
-
"span[class*='company'], "
|
| 46 |
-
"a[class*='company']"
|
| 47 |
)
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
"span[data-automation='jobLocation'], "
|
| 51 |
-
"span[class*='location'], "
|
| 52 |
-
"[data-testid='job-location']"
|
| 53 |
-
)
|
| 54 |
-
|
| 55 |
-
if not title_el:
|
| 56 |
-
continue
|
| 57 |
|
| 58 |
-
|
| 59 |
-
company_text = company_el.text.strip() if company_el else ""
|
| 60 |
-
location_text = location_el.text.strip() if location_el else ""
|
| 61 |
|
| 62 |
-
|
| 63 |
-
if not href:
|
| 64 |
-
continue
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
job_url = urljoin(base_url, href.split("?")[0])
|
| 70 |
|
| 71 |
-
# Visit detail page for description
|
| 72 |
-
description = ""
|
| 73 |
-
skills = []
|
| 74 |
try:
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
"
|
| 83 |
-
"
|
| 84 |
-
"
|
| 85 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
)
|
| 87 |
-
if desc_el:
|
| 88 |
-
description = desc_el.text.strip()
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
)
|
| 96 |
-
if
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
detail_page.close()
|
| 100 |
-
except Exception:
|
| 101 |
-
pass
|
| 102 |
-
|
| 103 |
-
if not location_text:
|
| 104 |
-
location_text = "Indonesia"
|
| 105 |
-
|
| 106 |
-
skills = extract_skills(description)
|
| 107 |
-
|
| 108 |
-
jobs.append({
|
| 109 |
-
"title": title_text,
|
| 110 |
-
"company": company_text,
|
| 111 |
-
"location": location_text,
|
| 112 |
-
"url": job_url,
|
| 113 |
-
"description": description,
|
| 114 |
-
"skills": skills,
|
| 115 |
-
})
|
| 116 |
-
except Exception:
|
| 117 |
-
continue
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
|
|
|
| 122 |
return jobs
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
from bs4 import BeautifulSoup
|
|
|
|
|
|
|
| 4 |
from urllib.parse import urljoin
|
| 5 |
+
|
| 6 |
+
from base import goto_with_retry, human_delay, jitter_scroll, short_delay
|
| 7 |
+
from browser import get_browser
|
| 8 |
+
from extract import extract_contact, extract_job_type, extract_salary
|
| 9 |
from skills_keyword import extract_skills
|
| 10 |
|
| 11 |
+
log = logging.getLogger(__name__)
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
def scrape_jobstreet(keyword="developer", max_jobs=25):
|
| 15 |
+
p, browser, context = get_browser()
|
| 16 |
+
jobs = []
|
| 17 |
|
| 18 |
try:
|
| 19 |
+
page = context.new_page()
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
base_url = "https://id.jobstreet.com"
|
| 22 |
+
slug = keyword.strip().lower().replace(" ", "-")
|
| 23 |
+
url = f"{base_url}/id/{slug}-jobs"
|
| 24 |
|
| 25 |
+
if not goto_with_retry(page, url):
|
| 26 |
+
log.warning("[JobStreet] Gagal memuat halaman pencarian: %s", url)
|
| 27 |
+
return jobs
|
| 28 |
|
| 29 |
+
human_delay()
|
| 30 |
+
jitter_scroll(page)
|
| 31 |
+
human_delay()
|
|
|
|
| 32 |
|
|
|
|
| 33 |
try:
|
| 34 |
+
page.wait_for_selector(
|
| 35 |
+
"article[data-testid='job-card'], article[data-automation='job-card']",
|
| 36 |
+
timeout=30000,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
)
|
| 38 |
+
except Exception:
|
| 39 |
+
log.warning("[JobStreet] Kartu lowongan tidak muncul untuk keyword: %s", keyword)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
soup = BeautifulSoup(page.content(), "html.parser")
|
|
|
|
|
|
|
| 42 |
|
| 43 |
+
cards = soup.select("article[data-testid='job-card'], article[data-automation='job-card'], article")
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
for card in cards:
|
| 46 |
+
if len(jobs) >= max_jobs:
|
| 47 |
+
break
|
|
|
|
| 48 |
|
|
|
|
|
|
|
|
|
|
| 49 |
try:
|
| 50 |
+
title_el = card.select_one(
|
| 51 |
+
"a[data-automation='jobTitle'], "
|
| 52 |
+
"h1[data-automation='job-detail-title'], "
|
| 53 |
+
"a[data-testid='job-title'], "
|
| 54 |
+
"h3 a, h2 a, a[href*='/job/']"
|
| 55 |
+
)
|
| 56 |
+
company_el = card.select_one(
|
| 57 |
+
"span[data-automation='jobCompany'], "
|
| 58 |
+
"a[data-automation='jobCompany'], "
|
| 59 |
+
"[data-testid='company-name'], "
|
| 60 |
+
"span[class*='company'], "
|
| 61 |
+
"a[class*='company']"
|
| 62 |
+
)
|
| 63 |
+
location_el = card.select_one(
|
| 64 |
+
"span[data-automation='jobLocation'], "
|
| 65 |
+
"span[class*='location'], "
|
| 66 |
+
"[data-testid='job-location']"
|
| 67 |
)
|
|
|
|
|
|
|
| 68 |
|
| 69 |
+
if not title_el:
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
title_text = title_el.text.strip()
|
| 73 |
+
company_text = company_el.text.strip() if company_el else ""
|
| 74 |
+
location_text = location_el.text.strip() if location_el else ""
|
| 75 |
+
|
| 76 |
+
href = title_el.get("href", "")
|
| 77 |
+
if not href:
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
if href.startswith("http"):
|
| 81 |
+
job_url = href.split("?")[0]
|
| 82 |
+
else:
|
| 83 |
+
job_url = urljoin(base_url, href.split("?")[0])
|
| 84 |
+
|
| 85 |
+
description = ""
|
| 86 |
+
salary = None
|
| 87 |
+
job_type = "Full-time"
|
| 88 |
+
contact_info = None
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
detail_page = context.new_page()
|
| 92 |
+
detail_page.goto(job_url, wait_until="domcontentloaded", timeout=15000)
|
| 93 |
+
short_delay()
|
| 94 |
+
detail_soup = BeautifulSoup(detail_page.content(), "html.parser")
|
| 95 |
+
|
| 96 |
+
desc_el = detail_soup.select_one(
|
| 97 |
+
"div[data-automation='jobAdDetails'], "
|
| 98 |
+
"div[class*='description'], "
|
| 99 |
+
"section[class*='description'], "
|
| 100 |
+
"article"
|
| 101 |
)
|
| 102 |
+
if desc_el:
|
| 103 |
+
description = desc_el.text.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
+
salary_el = detail_soup.select_one(
|
| 106 |
+
"span[data-automation='jobSalary'], "
|
| 107 |
+
"span[class*='salary']"
|
| 108 |
+
)
|
| 109 |
+
if salary_el:
|
| 110 |
+
salary = salary_el.text.strip() or None
|
| 111 |
+
if not salary:
|
| 112 |
+
page_text = detail_soup.get_text(" ", strip=True)
|
| 113 |
+
salary = extract_salary(description or page_text)
|
| 114 |
+
|
| 115 |
+
page_text = detail_soup.get_text(" ", strip=True)
|
| 116 |
+
job_type = extract_job_type(page_text)
|
| 117 |
+
contact_info = extract_contact(description or page_text)
|
| 118 |
+
|
| 119 |
+
if not location_text:
|
| 120 |
+
loc_el = detail_soup.select_one(
|
| 121 |
+
"span[data-automation='jobLocation'], "
|
| 122 |
+
"span[class*='location']"
|
| 123 |
+
)
|
| 124 |
+
if loc_el:
|
| 125 |
+
location_text = loc_el.text.strip()
|
| 126 |
+
|
| 127 |
+
detail_page.close()
|
| 128 |
+
except Exception as e:
|
| 129 |
+
log.debug("[JobStreet] Gagal ambil detail %s: %s", job_url, e)
|
| 130 |
+
|
| 131 |
+
if not location_text:
|
| 132 |
+
location_text = "Indonesia"
|
| 133 |
+
|
| 134 |
+
jobs.append({
|
| 135 |
+
"title": title_text,
|
| 136 |
+
"company": company_text,
|
| 137 |
+
"location": location_text,
|
| 138 |
+
"type": job_type,
|
| 139 |
+
"salary": salary,
|
| 140 |
+
"url": job_url,
|
| 141 |
+
"description": description,
|
| 142 |
+
"skills": extract_skills(description),
|
| 143 |
+
"contact_info": contact_info,
|
| 144 |
+
})
|
| 145 |
+
except Exception as e:
|
| 146 |
+
log.debug("[JobStreet] Gagal proses kartu: %s", e)
|
| 147 |
+
continue
|
| 148 |
+
finally:
|
| 149 |
+
try:
|
| 150 |
+
browser.close()
|
| 151 |
+
except Exception:
|
| 152 |
+
pass
|
| 153 |
+
p.stop()
|
| 154 |
|
| 155 |
+
log.info("[JobStreet] Keyword '%s' menghasilkan %d lowongan", keyword, len(jobs))
|
| 156 |
return jobs
|
linkedin.py
CHANGED
|
@@ -1,110 +1,135 @@
|
|
|
|
|
|
|
|
| 1 |
from bs4 import BeautifulSoup
|
| 2 |
-
from browser import get_browser
|
| 3 |
-
from base import human_delay, jitter_scroll
|
| 4 |
from urllib.parse import urljoin
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from skills_keyword import extract_skills
|
| 6 |
|
|
|
|
| 7 |
|
| 8 |
-
def scrape_linkedin(keyword="software engineer"):
|
| 9 |
-
p, browser, context = get_browser()
|
| 10 |
-
page = context.new_page()
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
|
|
|
| 14 |
|
| 15 |
try:
|
| 16 |
-
page
|
| 17 |
-
except Exception:
|
| 18 |
-
page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
| 19 |
-
|
| 20 |
-
human_delay()
|
| 21 |
-
jitter_scroll(page)
|
| 22 |
-
human_delay()
|
| 23 |
|
| 24 |
-
|
|
|
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
|
|
|
| 30 |
|
| 31 |
-
for card in cards:
|
| 32 |
try:
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
company_el = card.select_one(
|
| 39 |
-
"h4.base-search-card__subtitle, "
|
| 40 |
-
"a.hidden-nested-link, "
|
| 41 |
-
"h4"
|
| 42 |
-
)
|
| 43 |
-
location_el = card.select_one(
|
| 44 |
-
"span.job-search-card__location, "
|
| 45 |
-
"span[class*='location']"
|
| 46 |
-
)
|
| 47 |
-
link_el = card.select_one("a.base-card__full-link, a[href*='/jobs/view/']")
|
| 48 |
-
|
| 49 |
-
if not (title_el and company_el and link_el):
|
| 50 |
-
continue
|
| 51 |
|
| 52 |
-
|
| 53 |
-
if href.startswith("http"):
|
| 54 |
-
job_url = href.split("?")[0]
|
| 55 |
-
else:
|
| 56 |
-
job_url = urljoin(base_url, href.split("?")[0])
|
| 57 |
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
# Visit detail page for description
|
| 61 |
-
description = ""
|
| 62 |
-
skills = []
|
| 63 |
try:
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
# LinkedIn description sections
|
| 70 |
-
desc_el = detail_soup.select_one(
|
| 71 |
-
"div.description__text, "
|
| 72 |
-
"div[class*='description'], "
|
| 73 |
-
"section[class*='description']"
|
| 74 |
)
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
-
# Extract location from detail if not found in listing
|
| 79 |
if not location_text:
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
)
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
"location": location_text,
|
| 100 |
-
"url": job_url,
|
| 101 |
-
"description": description,
|
| 102 |
-
"skills": skills,
|
| 103 |
-
})
|
| 104 |
except Exception:
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
browser.close()
|
| 108 |
-
p.stop()
|
| 109 |
|
|
|
|
| 110 |
return jobs
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
from bs4 import BeautifulSoup
|
|
|
|
|
|
|
| 4 |
from urllib.parse import urljoin
|
| 5 |
+
|
| 6 |
+
from base import goto_with_retry, human_delay, jitter_scroll, short_delay
|
| 7 |
+
from browser import get_browser
|
| 8 |
+
from extract import extract_contact, extract_job_type, extract_salary
|
| 9 |
from skills_keyword import extract_skills
|
| 10 |
|
| 11 |
+
log = logging.getLogger(__name__)
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
def scrape_linkedin(keyword="software engineer", max_jobs=25):
|
| 15 |
+
p, browser, context = get_browser()
|
| 16 |
+
jobs = []
|
| 17 |
|
| 18 |
try:
|
| 19 |
+
page = context.new_page()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
+
base_url = "https://www.linkedin.com"
|
| 22 |
+
url = f"{base_url}/jobs/search/?keywords={keyword}&location=Indonesia"
|
| 23 |
|
| 24 |
+
if not goto_with_retry(page, url):
|
| 25 |
+
log.warning("[LinkedIn] Gagal memuat halaman pencarian: %s", url)
|
| 26 |
+
return jobs
|
| 27 |
|
| 28 |
+
human_delay()
|
| 29 |
+
jitter_scroll(page)
|
| 30 |
+
human_delay()
|
| 31 |
|
|
|
|
| 32 |
try:
|
| 33 |
+
page.wait_for_selector("div.base-card", timeout=30000)
|
| 34 |
+
except Exception:
|
| 35 |
+
log.warning("[LinkedIn] Kartu lowongan tidak muncul untuk keyword: %s", keyword)
|
| 36 |
+
|
| 37 |
+
soup = BeautifulSoup(page.content(), "html.parser")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
cards = soup.select("div.base-card")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
for card in cards:
|
| 42 |
+
if len(jobs) >= max_jobs:
|
| 43 |
+
break
|
| 44 |
|
|
|
|
|
|
|
|
|
|
| 45 |
try:
|
| 46 |
+
title_el = card.select_one(
|
| 47 |
+
"h3.base-search-card__title, "
|
| 48 |
+
"span.screen-reader-text, "
|
| 49 |
+
"h3"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
)
|
| 51 |
+
company_el = card.select_one(
|
| 52 |
+
"h4.base-search-card__subtitle, "
|
| 53 |
+
"a.hidden-nested-link, "
|
| 54 |
+
"h4"
|
| 55 |
+
)
|
| 56 |
+
location_el = card.select_one(
|
| 57 |
+
"span.job-search-card__location, "
|
| 58 |
+
"span[class*='location']"
|
| 59 |
+
)
|
| 60 |
+
link_el = card.select_one("a.base-card__full-link, a[href*='/jobs/view/']")
|
| 61 |
+
|
| 62 |
+
if not (title_el and company_el and link_el):
|
| 63 |
+
continue
|
| 64 |
+
|
| 65 |
+
href = link_el.get("href", "")
|
| 66 |
+
if href.startswith("http"):
|
| 67 |
+
job_url = href.split("?")[0]
|
| 68 |
+
else:
|
| 69 |
+
job_url = urljoin(base_url, href.split("?")[0])
|
| 70 |
+
|
| 71 |
+
location_text = location_el.text.strip() if location_el else ""
|
| 72 |
+
|
| 73 |
+
description = ""
|
| 74 |
+
salary = None
|
| 75 |
+
job_type = "Full-time"
|
| 76 |
+
contact_info = None
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
detail_page = context.new_page()
|
| 80 |
+
detail_page.goto(job_url, wait_until="domcontentloaded", timeout=15000)
|
| 81 |
+
short_delay()
|
| 82 |
+
detail_soup = BeautifulSoup(detail_page.content(), "html.parser")
|
| 83 |
+
|
| 84 |
+
desc_el = detail_soup.select_one(
|
| 85 |
+
"div.description__text, "
|
| 86 |
+
"div[class*='description'], "
|
| 87 |
+
"section[class*='description']"
|
| 88 |
+
)
|
| 89 |
+
if desc_el:
|
| 90 |
+
description = desc_el.text.strip()
|
| 91 |
+
|
| 92 |
+
page_text = detail_soup.get_text(" ", strip=True)
|
| 93 |
+
|
| 94 |
+
salary = extract_salary(description or page_text)
|
| 95 |
+
job_type = extract_job_type(page_text)
|
| 96 |
+
contact_info = extract_contact(description or page_text)
|
| 97 |
+
|
| 98 |
+
if not location_text:
|
| 99 |
+
loc_el = detail_soup.select_one(
|
| 100 |
+
"div[class*='location'], "
|
| 101 |
+
"span[class*='location']"
|
| 102 |
+
)
|
| 103 |
+
if loc_el:
|
| 104 |
+
location_text = loc_el.text.strip()
|
| 105 |
+
|
| 106 |
+
detail_page.close()
|
| 107 |
+
except Exception as e:
|
| 108 |
+
log.debug("[LinkedIn] Gagal ambil detail %s: %s", job_url, e)
|
| 109 |
|
|
|
|
| 110 |
if not location_text:
|
| 111 |
+
location_text = "Indonesia"
|
| 112 |
+
|
| 113 |
+
jobs.append({
|
| 114 |
+
"title": title_el.text.strip(),
|
| 115 |
+
"company": company_el.text.strip(),
|
| 116 |
+
"location": location_text,
|
| 117 |
+
"type": job_type,
|
| 118 |
+
"salary": salary,
|
| 119 |
+
"url": job_url,
|
| 120 |
+
"description": description,
|
| 121 |
+
"skills": extract_skills(description),
|
| 122 |
+
"contact_info": contact_info,
|
| 123 |
+
})
|
| 124 |
+
except Exception as e:
|
| 125 |
+
log.debug("[LinkedIn] Gagal proses kartu: %s", e)
|
| 126 |
+
continue
|
| 127 |
+
finally:
|
| 128 |
+
try:
|
| 129 |
+
browser.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
except Exception:
|
| 131 |
+
pass
|
| 132 |
+
p.stop()
|
|
|
|
|
|
|
| 133 |
|
| 134 |
+
log.info("[LinkedIn] Keyword '%s' menghasilkan %d lowongan", keyword, len(jobs))
|
| 135 |
return jobs
|
normalize.py
CHANGED
|
@@ -4,8 +4,10 @@ def normalize(job, source):
|
|
| 4 |
"company": job.get("company"),
|
| 5 |
"location": job.get("location", "Indonesia"),
|
| 6 |
"type": job.get("type", "Full-time"),
|
|
|
|
| 7 |
"description": job.get("description") or job.get("title", ""),
|
| 8 |
"skills": job.get("skills", []),
|
|
|
|
| 9 |
"url": job.get("url"),
|
| 10 |
"source": source,
|
| 11 |
"is_active": True
|
|
|
|
| 4 |
"company": job.get("company"),
|
| 5 |
"location": job.get("location", "Indonesia"),
|
| 6 |
"type": job.get("type", "Full-time"),
|
| 7 |
+
"salary": job.get("salary"),
|
| 8 |
"description": job.get("description") or job.get("title", ""),
|
| 9 |
"skills": job.get("skills", []),
|
| 10 |
+
"contact_info": job.get("contact_info"),
|
| 11 |
"url": job.get("url"),
|
| 12 |
"source": source,
|
| 13 |
"is_active": True
|
readme.md
CHANGED
|
@@ -5,15 +5,17 @@ Proyek ini adalah scraper lowongan kerja yang mengumpulkan data dari LinkedIn da
|
|
| 5 |
## Fitur
|
| 6 |
|
| 7 |
- Scraping lowongan dari **LinkedIn** dan **JobStreet**
|
|
|
|
| 8 |
- Automasi browser menggunakan Playwright
|
| 9 |
- Pembersihan dan normalisasi data sebelum disimpan
|
|
|
|
| 10 |
- Penyimpanan data ke Supabase dengan mekanisme upsert
|
| 11 |
-
- Penjadwalan scraping setiap 24 jam
|
| 12 |
|
| 13 |
## Setup
|
| 14 |
|
| 15 |
1. Clone repository ini
|
| 16 |
-
2. Buat virtual environment (
|
| 17 |
|
| 18 |
python -m venv venv
|
| 19 |
venv\Scripts\activate
|
|
@@ -26,34 +28,79 @@ Proyek ini adalah scraper lowongan kerja yang mengumpulkan data dari LinkedIn da
|
|
| 26 |
|
| 27 |
playwright install chromium
|
| 28 |
|
| 29 |
-
5.
|
|
|
|
|
|
|
| 30 |
|
| 31 |
SUPABASE_URL=your_supabase_url
|
| 32 |
SUPABASE_ANON_KEY=your_supabase_anon_key
|
| 33 |
|
| 34 |
-
6. Jalankan scraper:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
python run.py
|
| 37 |
|
| 38 |
## Struktur Modul
|
| 39 |
|
| 40 |
-
- `run.py` - Entry point, menjadwalkan scraping dan menyimpan data
|
| 41 |
- `linkedin.py` - Scraping lowongan dari LinkedIn
|
| 42 |
- `jobstreet.py` - Scraping lowongan dari JobStreet
|
| 43 |
- `browser.py` - Konfigurasi Playwright (Chromium headless)
|
| 44 |
-
- `base.py` - Helper delay
|
|
|
|
| 45 |
- `normalize.py` - Normalisasi format data sebelum disimpan
|
| 46 |
- `cleaner.py` - Validasi dan pembersihan data yang tidak valid
|
| 47 |
- `supabase_client.py` - Koneksi ke Supabase
|
|
|
|
|
|
|
| 48 |
|
| 49 |
## Database
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
- `
|
| 54 |
-
- `
|
| 55 |
-
- `
|
| 56 |
-
- `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
- `is_active`
|
| 58 |
|
| 59 |
-
Kolom `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
## Fitur
|
| 6 |
|
| 7 |
- Scraping lowongan dari **LinkedIn** dan **JobStreet**
|
| 8 |
+
- Multi-keyword scraping (daftar di `run.py`)
|
| 9 |
- Automasi browser menggunakan Playwright
|
| 10 |
- Pembersihan dan normalisasi data sebelum disimpan
|
| 11 |
+
- Ekstraksi otomatis skill, tipe kerja, gaji, dan kontak dari deskripsi
|
| 12 |
- Penyimpanan data ke Supabase dengan mekanisme upsert
|
| 13 |
+
- Penjadwalan scraping setiap 24 jam, atau mode `--once` untuk cron/CI
|
| 14 |
|
| 15 |
## Setup
|
| 16 |
|
| 17 |
1. Clone repository ini
|
| 18 |
+
2. Buat virtual environment (Python 3.10+):
|
| 19 |
|
| 20 |
python -m venv venv
|
| 21 |
venv\Scripts\activate
|
|
|
|
| 28 |
|
| 29 |
playwright install chromium
|
| 30 |
|
| 31 |
+
5. Salin file `.env.example` menjadi `.env`, lalu isi kredensial Supabase:
|
| 32 |
+
|
| 33 |
+
cp .env.example .env
|
| 34 |
|
| 35 |
SUPABASE_URL=your_supabase_url
|
| 36 |
SUPABASE_ANON_KEY=your_supabase_anon_key
|
| 37 |
|
| 38 |
+
6. Jalankan scraper (sekali jalan lalu keluar, cocok untuk cron/CI):
|
| 39 |
+
|
| 40 |
+
python run.py --once
|
| 41 |
+
|
| 42 |
+
Atau jalankan sebagai service berjalan terus (scrape tiap 24 jam):
|
| 43 |
|
| 44 |
python run.py
|
| 45 |
|
| 46 |
## Struktur Modul
|
| 47 |
|
| 48 |
+
- `run.py` - Entry point, multi-keyword, menjadwalkan scraping dan menyimpan data
|
| 49 |
- `linkedin.py` - Scraping lowongan dari LinkedIn
|
| 50 |
- `jobstreet.py` - Scraping lowongan dari JobStreet
|
| 51 |
- `browser.py` - Konfigurasi Playwright (Chromium headless)
|
| 52 |
+
- `base.py` - Helper delay, scroll, dan retry navigasi
|
| 53 |
+
- `extract.py` - Ekstraksi gaji, tipe kerja, dan kontak dari deskripsi
|
| 54 |
- `normalize.py` - Normalisasi format data sebelum disimpan
|
| 55 |
- `cleaner.py` - Validasi dan pembersihan data yang tidak valid
|
| 56 |
- `supabase_client.py` - Koneksi ke Supabase
|
| 57 |
+
- `skills_keyword.py` - Daftar keyword untuk ekstraksi skill
|
| 58 |
+
- `schema.sql` - Skema tabel `jobs` di Supabase
|
| 59 |
|
| 60 |
## Database
|
| 61 |
|
| 62 |
+
Jalankan `schema.sql` di Supabase untuk membuat tabel `jobs`, atau pastikan sudah ada dengan skema berikut:
|
| 63 |
+
|
| 64 |
+
- `title`
|
| 65 |
+
- `company`
|
| 66 |
+
- `location`
|
| 67 |
+
- `type` (Full-time / Part-time / Contract / Internship)
|
| 68 |
+
- `salary`
|
| 69 |
+
- `description`
|
| 70 |
+
- `skills` (jsonb)
|
| 71 |
+
- `contact_info`
|
| 72 |
+
- `url`
|
| 73 |
+
- `source`
|
| 74 |
- `is_active`
|
| 75 |
|
| 76 |
+
Kolom `url` bersifat unik dan digunakan sebagai konflik untuk upsert.
|
| 77 |
+
|
| 78 |
+
## Deployment
|
| 79 |
+
|
| 80 |
+
### GitHub Actions (gratis, untuk demo skripsi)
|
| 81 |
+
|
| 82 |
+
Jalankan scraping setiap 24 jam lewat cron workflow, contoh `.github/workflows/scrape.yml`:
|
| 83 |
+
|
| 84 |
+
```yaml
|
| 85 |
+
name: scrape
|
| 86 |
+
on:
|
| 87 |
+
schedule:
|
| 88 |
+
- cron: "0 3 * * *"
|
| 89 |
+
workflow_dispatch:
|
| 90 |
+
jobs:
|
| 91 |
+
run:
|
| 92 |
+
runs-on: ubuntu-latest
|
| 93 |
+
steps:
|
| 94 |
+
- uses: actions/checkout@v4
|
| 95 |
+
- uses: actions/setup-python@v5
|
| 96 |
+
with:
|
| 97 |
+
python-version: "3.12"
|
| 98 |
+
- run: pip install -r requirements.txt
|
| 99 |
+
- run: python -m playwright install --with-deps chromium
|
| 100 |
+
- run: python run.py --once
|
| 101 |
+
env:
|
| 102 |
+
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
| 103 |
+
SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
Atur `SUPABASE_URL` dan `SUPABASE_ANON_KEY` di *Settings → Secrets and variables → Actions*. Alternatif lain: Railway/Render (cron bawaan) atau VPS murah.
|
requirements.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
beautifulsoup4==4.12.3
|
| 2 |
-
playwright==1.
|
| 3 |
python-dotenv==1.0.1
|
| 4 |
schedule==1.2.2
|
| 5 |
-
supabase==2.
|
|
|
|
| 1 |
beautifulsoup4==4.12.3
|
| 2 |
+
playwright==1.62.0
|
| 3 |
python-dotenv==1.0.1
|
| 4 |
schedule==1.2.2
|
| 5 |
+
supabase==2.31.0
|
run.py
CHANGED
|
@@ -1,12 +1,33 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from jobstreet import scrape_jobstreet
|
|
|
|
| 3 |
from normalize import normalize
|
| 4 |
-
from cleaner import clean_job
|
| 5 |
from supabase_client import supabase
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
def push(jobs, source):
|
|
@@ -33,23 +54,27 @@ def push(jobs, source):
|
|
| 33 |
.upsert(batch, on_conflict="url") \
|
| 34 |
.execute()
|
| 35 |
except Exception as e:
|
| 36 |
-
|
| 37 |
return 0
|
| 38 |
|
| 39 |
return len(batch)
|
| 40 |
|
| 41 |
|
| 42 |
-
def
|
| 43 |
try:
|
| 44 |
-
|
| 45 |
-
linkedin_jobs = scrape_linkedin()
|
| 46 |
-
print(f"LinkedIn ditemukan: {len(linkedin_jobs)}, dimasukkan: {push(linkedin_jobs, 'LinkedIn')}")
|
| 47 |
-
|
| 48 |
-
print("Scraping JobStreet...")
|
| 49 |
-
jobstreet_jobs = scrape_jobstreet()
|
| 50 |
-
print(f"JobStreet ditemukan: {len(jobstreet_jobs)}, dimasukkan: {push(jobstreet_jobs, 'JobStreet')}")
|
| 51 |
except Exception as e:
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
running = True
|
|
@@ -57,23 +82,31 @@ running = True
|
|
| 57 |
|
| 58 |
def shutdown(signum, frame):
|
| 59 |
global running
|
| 60 |
-
|
| 61 |
running = False
|
| 62 |
|
| 63 |
|
| 64 |
if __name__ == "__main__":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
signal.signal(signal.SIGINT, shutdown)
|
| 66 |
signal.signal(signal.SIGTERM, shutdown)
|
| 67 |
|
| 68 |
main() # jalankan langsung saat pertama kali start
|
| 69 |
|
|
|
|
|
|
|
|
|
|
| 70 |
schedule.every(24).hours.do(main)
|
| 71 |
-
|
| 72 |
-
|
| 73 |
|
| 74 |
while running:
|
| 75 |
schedule.run_pending()
|
| 76 |
time.sleep(60)
|
| 77 |
|
| 78 |
-
|
| 79 |
-
sys.exit(0)
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import logging
|
| 3 |
+
import signal
|
| 4 |
+
import sys
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
import schedule
|
| 8 |
+
|
| 9 |
+
from cleaner import clean_job
|
| 10 |
from jobstreet import scrape_jobstreet
|
| 11 |
+
from linkedin import scrape_linkedin
|
| 12 |
from normalize import normalize
|
|
|
|
| 13 |
from supabase_client import supabase
|
| 14 |
+
|
| 15 |
+
logging.basicConfig(
|
| 16 |
+
level=logging.INFO,
|
| 17 |
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
| 18 |
+
)
|
| 19 |
+
log = logging.getLogger("scraper")
|
| 20 |
+
|
| 21 |
+
KEYWORDS = [
|
| 22 |
+
"software engineer",
|
| 23 |
+
"backend developer",
|
| 24 |
+
"frontend developer",
|
| 25 |
+
"data scientist",
|
| 26 |
+
"full stack developer",
|
| 27 |
+
"devops engineer",
|
| 28 |
+
"ui ux designer",
|
| 29 |
+
"mobile developer",
|
| 30 |
+
]
|
| 31 |
|
| 32 |
|
| 33 |
def push(jobs, source):
|
|
|
|
| 54 |
.upsert(batch, on_conflict="url") \
|
| 55 |
.execute()
|
| 56 |
except Exception as e:
|
| 57 |
+
log.error("[Supabase] Upsert gagal (%s): %s", source, e)
|
| 58 |
return 0
|
| 59 |
|
| 60 |
return len(batch)
|
| 61 |
|
| 62 |
|
| 63 |
+
def scrape_source(scraper, source, keyword):
|
| 64 |
try:
|
| 65 |
+
jobs = scraper(keyword)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
except Exception as e:
|
| 67 |
+
log.error("[%s] Scraping gagal (keyword=%s): %s", source, keyword, e)
|
| 68 |
+
return
|
| 69 |
+
|
| 70 |
+
inserted = push(jobs, source)
|
| 71 |
+
log.info("[%s] %s: %d ditemukan, %d dimasukkan", source, keyword, len(jobs), inserted)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def main():
|
| 75 |
+
for keyword in KEYWORDS:
|
| 76 |
+
scrape_source(scrape_linkedin, "LinkedIn", keyword)
|
| 77 |
+
scrape_source(scrape_jobstreet, "JobStreet", keyword)
|
| 78 |
|
| 79 |
|
| 80 |
running = True
|
|
|
|
| 82 |
|
| 83 |
def shutdown(signum, frame):
|
| 84 |
global running
|
| 85 |
+
log.info("Ctrl+C ditekan. Menghentikan scraper...")
|
| 86 |
running = False
|
| 87 |
|
| 88 |
|
| 89 |
if __name__ == "__main__":
|
| 90 |
+
parser = argparse.ArgumentParser(description="Job scraper LinkedIn & JobStreet")
|
| 91 |
+
parser.add_argument("--once", action="store_true",
|
| 92 |
+
help="Jalankan sekali lalu keluar (untuk cron/CI)")
|
| 93 |
+
args = parser.parse_args()
|
| 94 |
+
|
| 95 |
signal.signal(signal.SIGINT, shutdown)
|
| 96 |
signal.signal(signal.SIGTERM, shutdown)
|
| 97 |
|
| 98 |
main() # jalankan langsung saat pertama kali start
|
| 99 |
|
| 100 |
+
if args.once:
|
| 101 |
+
sys.exit(0)
|
| 102 |
+
|
| 103 |
schedule.every(24).hours.do(main)
|
| 104 |
+
log.info("Job scraper berjalan setiap 24 jam...")
|
| 105 |
+
log.info("Tekan Ctrl+C untuk menghentikan.")
|
| 106 |
|
| 107 |
while running:
|
| 108 |
schedule.run_pending()
|
| 109 |
time.sleep(60)
|
| 110 |
|
| 111 |
+
log.info("Scraper dihentikan. Sampai jumpa!")
|
| 112 |
+
sys.exit(0)
|
skills_keyword.py
CHANGED
|
@@ -3,13 +3,13 @@ import re
|
|
| 3 |
|
| 4 |
SKILLS_DB = [
|
| 5 |
# Programming Languages
|
| 6 |
-
"python", "javascript", "typescript", "java", "c++", "c#", "
|
| 7 |
-
"rust", "kotlin", "swift", "php", "ruby", "scala", "
|
| 8 |
"shell", "bash", "powershell",
|
| 9 |
|
| 10 |
# Web Frontend
|
| 11 |
"react", "react native", "vue", "vue.js", "angular", "next.js", "nextjs",
|
| 12 |
-
"nuxt", "svelte", "html", "css", "scss", "sass", "
|
| 13 |
"bootstrap", "jquery",
|
| 14 |
|
| 15 |
# Web Backend
|
|
@@ -36,7 +36,7 @@ SKILLS_DB = [
|
|
| 36 |
"natural language processing", "nlp", "computer vision",
|
| 37 |
|
| 38 |
# Mobile
|
| 39 |
-
"flutter", "
|
| 40 |
|
| 41 |
# Testing
|
| 42 |
"selenium", "jest", "pytest", "mocha", "cypress", "unit testing",
|
|
@@ -51,6 +51,35 @@ SKILLS_DB = [
|
|
| 51 |
]
|
| 52 |
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
def extract_skills(text):
|
| 55 |
"""Extract skills from job description text using keyword matching."""
|
| 56 |
if not text:
|
|
@@ -64,4 +93,6 @@ def extract_skills(text):
|
|
| 64 |
if re.search(pattern, text_lower):
|
| 65 |
found.append(skill)
|
| 66 |
|
|
|
|
|
|
|
| 67 |
return list(set(found))
|
|
|
|
| 3 |
|
| 4 |
SKILLS_DB = [
|
| 5 |
# Programming Languages
|
| 6 |
+
"python", "javascript", "typescript", "java", "c++", "c#", "golang",
|
| 7 |
+
"rust", "kotlin", "swift", "php", "ruby", "scala", "dart",
|
| 8 |
"shell", "bash", "powershell",
|
| 9 |
|
| 10 |
# Web Frontend
|
| 11 |
"react", "react native", "vue", "vue.js", "angular", "next.js", "nextjs",
|
| 12 |
+
"nuxt", "svelte", "html", "css", "scss", "sass", "tailwind",
|
| 13 |
"bootstrap", "jquery",
|
| 14 |
|
| 15 |
# Web Backend
|
|
|
|
| 36 |
"natural language processing", "nlp", "computer vision",
|
| 37 |
|
| 38 |
# Mobile
|
| 39 |
+
"flutter", "ios", "android",
|
| 40 |
|
| 41 |
# Testing
|
| 42 |
"selenium", "jest", "pytest", "mocha", "cypress", "unit testing",
|
|
|
|
| 51 |
]
|
| 52 |
|
| 53 |
|
| 54 |
+
# Kata ambigu yang hanya dianggap skill jika muncul dengan konteks teknis.
|
| 55 |
+
# Pola kecocokan disimpan lowercase.
|
| 56 |
+
AMBIGUOUS = {
|
| 57 |
+
"go": [
|
| 58 |
+
r"\bgo\s+(language|lang|development|developer|programming)\b",
|
| 59 |
+
r"\bwritten?\s+in\s+go\b",
|
| 60 |
+
r"\bgo\s+golang\b",
|
| 61 |
+
],
|
| 62 |
+
"r": [
|
| 63 |
+
r"\br\s+(language|programming|studio)\b",
|
| 64 |
+
],
|
| 65 |
+
"less": [
|
| 66 |
+
r"\bless\s+(css|preprocessor)\b",
|
| 67 |
+
r"\bscss\s*/\s*less\b",
|
| 68 |
+
r"\bsass\s*/\s*less\b",
|
| 69 |
+
],
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _match_ambiguous(text):
|
| 74 |
+
found = []
|
| 75 |
+
for skill, patterns in AMBIGUOUS.items():
|
| 76 |
+
for pattern in patterns:
|
| 77 |
+
if re.search(pattern, text, re.IGNORECASE):
|
| 78 |
+
found.append(skill)
|
| 79 |
+
break
|
| 80 |
+
return found
|
| 81 |
+
|
| 82 |
+
|
| 83 |
def extract_skills(text):
|
| 84 |
"""Extract skills from job description text using keyword matching."""
|
| 85 |
if not text:
|
|
|
|
| 93 |
if re.search(pattern, text_lower):
|
| 94 |
found.append(skill)
|
| 95 |
|
| 96 |
+
found += _match_ambiguous(text_lower)
|
| 97 |
+
|
| 98 |
return list(set(found))
|