Spaces:
Sleeping
Sleeping
Commit ·
079f46c
0
Parent(s):
inisialisasi projek
Browse files- .env.example +2 -0
- .gitignore +9 -0
- base.py +11 -0
- browser.py +18 -0
- cleaner.py +20 -0
- jobstreet.py +41 -0
- linkedin.py +42 -0
- normalize.py +9 -0
- readme.md +59 -0
- requirements.txt +5 -0
- run.py +56 -0
- supabase_client.py +16 -0
.env.example
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SUPABASE_URL=your_supabase_url_here
|
| 2 |
+
SUPABASE_KEY=your_supabase_anon_key_here
|
.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
|
| 5 |
+
# Local configuration and secrets
|
| 6 |
+
.env
|
| 7 |
+
|
| 8 |
+
# VS Code settings
|
| 9 |
+
.vscode/
|
base.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
""")
|
browser.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from playwright.sync_api import sync_playwright
|
| 2 |
+
|
| 3 |
+
def get_browser():
|
| 4 |
+
p = sync_playwright().start()
|
| 5 |
+
|
| 6 |
+
browser = p.chromium.launch(
|
| 7 |
+
headless=True,
|
| 8 |
+
args=[
|
| 9 |
+
"--disable-blink-features=AutomationControlled"
|
| 10 |
+
]
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
context = browser.new_context(
|
| 14 |
+
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36",
|
| 15 |
+
viewport={"width": 1280, "height": 800}
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
return p, browser, context
|
cleaner.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def clean_job(job):
|
| 2 |
+
company = job.get("company")
|
| 3 |
+
title = job.get("title")
|
| 4 |
+
url = job.get("url")
|
| 5 |
+
|
| 6 |
+
# safety check
|
| 7 |
+
if not company or not title or not url:
|
| 8 |
+
return None
|
| 9 |
+
|
| 10 |
+
company = company.strip()
|
| 11 |
+
title = title.strip()
|
| 12 |
+
url = url.strip()
|
| 13 |
+
|
| 14 |
+
if len(company) < 2 or len(title) < 5:
|
| 15 |
+
return None
|
| 16 |
+
|
| 17 |
+
if not url.startswith("http"):
|
| 18 |
+
return None
|
| 19 |
+
|
| 20 |
+
return job
|
jobstreet.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from bs4 import BeautifulSoup
|
| 2 |
+
from browser import get_browser
|
| 3 |
+
from base import human_delay, jitter_scroll
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def scrape_jobstreet(keyword="developer"):
|
| 7 |
+
p, browser, context = get_browser()
|
| 8 |
+
page = context.new_page()
|
| 9 |
+
|
| 10 |
+
url = f"https://www.jobstreet.co.id/id/job-search/{keyword}-jobs/"
|
| 11 |
+
|
| 12 |
+
page.goto(url, wait_until="domcontentloaded")
|
| 13 |
+
human_delay()
|
| 14 |
+
|
| 15 |
+
jitter_scroll(page)
|
| 16 |
+
human_delay()
|
| 17 |
+
|
| 18 |
+
soup = BeautifulSoup(page.content(), "html.parser")
|
| 19 |
+
|
| 20 |
+
jobs = []
|
| 21 |
+
|
| 22 |
+
cards = soup.select("article")
|
| 23 |
+
|
| 24 |
+
for card in cards:
|
| 25 |
+
try:
|
| 26 |
+
title = card.select_one("a")
|
| 27 |
+
company = card.select_one("span")
|
| 28 |
+
|
| 29 |
+
if title:
|
| 30 |
+
jobs.append({
|
| 31 |
+
"title": title.text.strip(),
|
| 32 |
+
"company": company.text.strip() if company else "",
|
| 33 |
+
"url": title["href"]
|
| 34 |
+
})
|
| 35 |
+
except:
|
| 36 |
+
continue
|
| 37 |
+
|
| 38 |
+
browser.close()
|
| 39 |
+
p.stop()
|
| 40 |
+
|
| 41 |
+
return jobs
|
linkedin.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from bs4 import BeautifulSoup
|
| 2 |
+
from browser import get_browser
|
| 3 |
+
from base import human_delay, jitter_scroll
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def scrape_linkedin(keyword="software engineer"):
|
| 7 |
+
p, browser, context = get_browser()
|
| 8 |
+
page = context.new_page()
|
| 9 |
+
|
| 10 |
+
url = f"https://www.linkedin.com/jobs/search/?keywords={keyword}"
|
| 11 |
+
|
| 12 |
+
page.goto(url, wait_until="domcontentloaded")
|
| 13 |
+
human_delay()
|
| 14 |
+
|
| 15 |
+
jitter_scroll(page)
|
| 16 |
+
human_delay()
|
| 17 |
+
|
| 18 |
+
soup = BeautifulSoup(page.content(), "html.parser")
|
| 19 |
+
|
| 20 |
+
jobs = []
|
| 21 |
+
|
| 22 |
+
cards = soup.select("li")
|
| 23 |
+
|
| 24 |
+
for card in cards:
|
| 25 |
+
try:
|
| 26 |
+
title = card.select_one("h3")
|
| 27 |
+
company = card.select_one("h4")
|
| 28 |
+
link = card.select_one("a")
|
| 29 |
+
|
| 30 |
+
if title and company and link:
|
| 31 |
+
jobs.append({
|
| 32 |
+
"title": title.text.strip(),
|
| 33 |
+
"company": company.text.strip(),
|
| 34 |
+
"url": "https://www.linkedin.com" + link["href"]
|
| 35 |
+
})
|
| 36 |
+
except:
|
| 37 |
+
continue
|
| 38 |
+
|
| 39 |
+
browser.close()
|
| 40 |
+
p.stop()
|
| 41 |
+
|
| 42 |
+
return jobs
|
normalize.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def normalize(job, source):
|
| 2 |
+
return {
|
| 3 |
+
"nama_perusahaan": job.get("company"),
|
| 4 |
+
"posisi_jabatan": job.get("title"),
|
| 5 |
+
"deskripsi_kualifikasi": job.get("title"),
|
| 6 |
+
"link_pendaftaran": job.get("url"),
|
| 7 |
+
"sumber_data": source,
|
| 8 |
+
"is_active": True
|
| 9 |
+
}
|
readme.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Job Scraper
|
| 2 |
+
|
| 3 |
+
Proyek ini adalah scraper lowongan kerja yang mengumpulkan data dari LinkedIn dan JobStreet, membersihkannya, lalu menyimpannya ke database Supabase.
|
| 4 |
+
|
| 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 (disarankan Python 3.10+):
|
| 17 |
+
|
| 18 |
+
python -m venv venv
|
| 19 |
+
venv\Scripts\activate
|
| 20 |
+
|
| 21 |
+
3. Install dependensi:
|
| 22 |
+
|
| 23 |
+
pip install -r requirements.txt
|
| 24 |
+
|
| 25 |
+
4. Instal browser untuk Playwright:
|
| 26 |
+
|
| 27 |
+
playwright install chromium
|
| 28 |
+
|
| 29 |
+
5. Buat file `.env` di direktori proyek:
|
| 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 dan scroll untuk simulasi manusia
|
| 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 |
+
Pastikan tabel `lowongan_kerja` sudah ada di Supabase dengan kolom:
|
| 52 |
+
- `nama_perusahaan`
|
| 53 |
+
- `posisi_jabatan`
|
| 54 |
+
- `deskripsi_kualifikasi`
|
| 55 |
+
- `link_pendaftaran`
|
| 56 |
+
- `sumber_data`
|
| 57 |
+
- `is_active`
|
| 58 |
+
|
| 59 |
+
Kolom `link_pendaftaran` digunakan sebagai konflik untuk upsert.
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
beautifulsoup4==4.12.3
|
| 2 |
+
playwright==1.40.0
|
| 3 |
+
python-dotenv==1.0.1
|
| 4 |
+
schedule==1.2.2
|
| 5 |
+
supabase==2.3.4
|
run.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from linkedin import scrape_linkedin
|
| 2 |
+
from jobstreet import scrape_jobstreet
|
| 3 |
+
from normalize import normalize
|
| 4 |
+
from cleaner import clean_job
|
| 5 |
+
from supabase_client import supabase
|
| 6 |
+
import time
|
| 7 |
+
import schedule
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def push(jobs, source):
|
| 11 |
+
batch = []
|
| 12 |
+
seen = set()
|
| 13 |
+
|
| 14 |
+
for job in jobs:
|
| 15 |
+
|
| 16 |
+
# CLEANING STEP
|
| 17 |
+
job = clean_job(job)
|
| 18 |
+
if not job:
|
| 19 |
+
continue
|
| 20 |
+
|
| 21 |
+
url = job["url"]
|
| 22 |
+
|
| 23 |
+
if url in seen:
|
| 24 |
+
continue
|
| 25 |
+
seen.add(url)
|
| 26 |
+
|
| 27 |
+
batch.append(normalize(job, source))
|
| 28 |
+
|
| 29 |
+
if batch:
|
| 30 |
+
supabase.table("lowongan_kerja") \
|
| 31 |
+
.upsert(batch, on_conflict="link_pendaftaran") \
|
| 32 |
+
.execute()
|
| 33 |
+
|
| 34 |
+
return len(batch)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def main():
|
| 38 |
+
try:
|
| 39 |
+
print("Scraping LinkedIn...")
|
| 40 |
+
linkedin_jobs = scrape_linkedin()
|
| 41 |
+
print("LinkedIn inserted:", push(linkedin_jobs, "LinkedIn"))
|
| 42 |
+
|
| 43 |
+
print("Scraping JobStreet...")
|
| 44 |
+
jobstreet_jobs = scrape_jobstreet()
|
| 45 |
+
print("JobStreet inserted:", push(jobstreet_jobs, "JobStreet"))
|
| 46 |
+
except Exception as e:
|
| 47 |
+
print(f"Error during scraping: {e}")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
if __name__ == "__main__":
|
| 51 |
+
schedule.every(24).hours.do(main)
|
| 52 |
+
|
| 53 |
+
print("Job scraper running every 24 hours...")
|
| 54 |
+
while True:
|
| 55 |
+
schedule.run_pending()
|
| 56 |
+
time.sleep(60) # Check every minute
|
supabase_client.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dotenv import load_dotenv
|
| 2 |
+
import os
|
| 3 |
+
from supabase import create_client
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
def get_supabase():
|
| 8 |
+
url = os.getenv("SUPABASE_URL")
|
| 9 |
+
key = os.getenv("SUPABASE_ANON_KEY")
|
| 10 |
+
|
| 11 |
+
if not url or not key:
|
| 12 |
+
raise ValueError("Missing Supabase env")
|
| 13 |
+
|
| 14 |
+
return create_client(url, key)
|
| 15 |
+
|
| 16 |
+
supabase = get_supabase()
|