dimdimm commited on
Commit
e84ab4f
·
1 Parent(s): c775b3d

feat: upgrade scraper - multi keyword, --once mode, GitHub Actions workflow

Browse files
Files changed (12) hide show
  1. .env.example +1 -1
  2. .github/workflows/scrape.yml +28 -0
  3. base.py +19 -1
  4. cleaner.py +3 -0
  5. extract.py +47 -0
  6. jobstreet.py +131 -97
  7. linkedin.py +112 -87
  8. normalize.py +2 -0
  9. readme.md +60 -13
  10. requirements.txt +2 -2
  11. run.py +54 -21
  12. skills_keyword.py +35 -4
.env.example CHANGED
@@ -1,2 +1,2 @@
1
  SUPABASE_URL=your_supabase_url_here
2
- SUPABASE_KEY=your_supabase_anon_key_here
 
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
- base_url = "https://id.jobstreet.com"
13
- url = f"{base_url}/id/{keyword}-jobs"
 
14
 
15
  try:
16
- page.goto(url, wait_until="networkidle", timeout=30000)
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
- soup = BeautifulSoup(page.content(), "html.parser")
 
 
25
 
26
- jobs = []
27
-
28
- # JobStreet job cards are wrapped in <article> elements
29
- cards = soup.select("article[data-testid='job-card'], article[data-automation='job-card'], article")
30
 
31
- for card in cards:
32
  try:
33
- # Job title link
34
- title_el = card.select_one(
35
- "a[data-automation='jobTitle'], "
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
- # Location
49
- location_el = card.select_one(
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
- title_text = title_el.text.strip()
59
- company_text = company_el.text.strip() if company_el else ""
60
- location_text = location_el.text.strip() if location_el else ""
61
 
62
- href = title_el.get("href", "")
63
- if not href:
64
- continue
65
 
66
- if href.startswith("http"):
67
- job_url = href.split("?")[0]
68
- else:
69
- job_url = urljoin(base_url, href.split("?")[0])
70
 
71
- # Visit detail page for description
72
- description = ""
73
- skills = []
74
  try:
75
- detail_page = context.new_page()
76
- detail_page.goto(job_url, wait_until="domcontentloaded", timeout=15000)
77
- human_delay()
78
- detail_soup = BeautifulSoup(detail_page.content(), "html.parser")
79
-
80
- # JobStreet description sections
81
- desc_el = detail_soup.select_one(
82
- "div[data-automation='jobAdDetails'], "
83
- "div[class*='description'], "
84
- "section[class*='description'], "
85
- "article"
 
 
 
 
 
 
86
  )
87
- if desc_el:
88
- description = desc_el.text.strip()
89
 
90
- # Extract location from detail if not found in listing
91
- if not location_text:
92
- loc_el = detail_soup.select_one(
93
- "span[data-automation='jobLocation'], "
94
- "span[class*='location']"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  )
96
- if loc_el:
97
- location_text = loc_el.text.strip()
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
- browser.close()
120
- p.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- base_url = "https://www.linkedin.com"
13
- url = f"{base_url}/jobs/search/?keywords={keyword}&location=Indonesia"
 
14
 
15
  try:
16
- page.goto(url, wait_until="networkidle", timeout=30000)
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
- soup = BeautifulSoup(page.content(), "html.parser")
 
25
 
26
- jobs = []
 
 
27
 
28
- # LinkedIn public job cards use these class patterns
29
- cards = soup.select("div.base-card")
 
30
 
31
- for card in cards:
32
  try:
33
- title_el = card.select_one(
34
- "h3.base-search-card__title, "
35
- "span.screen-reader-text, "
36
- "h3"
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
- href = link_el.get("href", "")
53
- if href.startswith("http"):
54
- job_url = href.split("?")[0]
55
- else:
56
- job_url = urljoin(base_url, href.split("?")[0])
57
 
58
- location_text = location_el.text.strip() if location_el else ""
 
 
59
 
60
- # Visit detail page for description
61
- description = ""
62
- skills = []
63
  try:
64
- detail_page = context.new_page()
65
- detail_page.goto(job_url, wait_until="domcontentloaded", timeout=15000)
66
- human_delay()
67
- detail_soup = BeautifulSoup(detail_page.content(), "html.parser")
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
- if desc_el:
76
- description = desc_el.text.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
- # Extract location from detail if not found in listing
79
  if not location_text:
80
- loc_el = detail_soup.select_one(
81
- "div[class*='location'], "
82
- "span[class*='location']"
83
- )
84
- if loc_el:
85
- location_text = loc_el.text.strip()
86
-
87
- detail_page.close()
88
- except Exception:
89
- pass
90
-
91
- if not location_text:
92
- location_text = "Indonesia"
93
-
94
- skills = extract_skills(description)
95
-
96
- jobs.append({
97
- "title": title_el.text.strip(),
98
- "company": company_el.text.strip(),
99
- "location": location_text,
100
- "url": job_url,
101
- "description": description,
102
- "skills": skills,
103
- })
104
  except Exception:
105
- continue
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 (disarankan Python 3.10+):
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. 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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.40.0
3
  python-dotenv==1.0.1
4
  schedule==1.2.2
5
- supabase==2.3.4
 
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
- 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
- import signal
9
- import sys
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- print(f"[ERROR] Supabase upsert gagal ({source}): {e}")
37
  return 0
38
 
39
  return len(batch)
40
 
41
 
42
- def main():
43
  try:
44
- print("Scraping LinkedIn...")
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
- print(f"[ERROR] Scraping gagal: {e}")
 
 
 
 
 
 
 
 
 
 
53
 
54
 
55
  running = True
@@ -57,23 +82,31 @@ running = True
57
 
58
  def shutdown(signum, frame):
59
  global running
60
- print("\n[INFO] Ctrl+C ditekan. Menghentikan scraper...")
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
- print("Job scraper berjalan setiap 24 jam...")
72
- print("Tekan Ctrl+C untuk menghentikan.")
73
 
74
  while running:
75
  schedule.run_pending()
76
  time.sleep(60)
77
 
78
- print("[INFO] Scraper dihentikan. Sampai jumpa!")
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#", "go", "golang",
7
- "rust", "kotlin", "swift", "php", "ruby", "scala", "r", "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", "less", "tailwind",
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", "react native", "ios", "android", "swift", "kotlin",
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))