Build your own research collector (free Python lab)
0 / 10 steps
The goal
Build a small Python tool that collects information from sources you choose (a public data feed, a blog’s RSS feed and your own notes), cleans it, keeps it as a Markdown library with the source and date on every item, checks it against your own list of topics, and writes a short report: what’s new, what’s mentioned often, and which of your topics have little or no material. That last list is where the ideas come from. About 170 lines of Python, all explained.
Be a good guest on other people’s websites. Collect only from sources that allow it: public data feeds, RSS feeds and your own files are the safe bet. Your script will obey robots.txt, say who it is and pause between requests; keep it that way. What you collect is for your own reading. Don’t republish other people’s articles, and keep any attribution a source asks for.
Why this lab. Everyone who studies, works in security or just follows a field ends up with too many tabs and no idea what they’ve missed. A collector you wrote yourself fixes that, and you learn real skills on the way: reading feeds and JSON, polite fetching, text cleaning, regular expressions and simple state between runs. It is open-ended on purpose. Point it at exam objectives, threat feeds, job posts or a hobby, and it hands you a list of gaps to think about.
Some websites ask scripts to wait 35 seconds between requests. This one waits, every time, without complaining. I’ve asked Nova to take notes. — Nyx
Words you’ll meet
| Term | Meaning |
|---|---|
| RSS feed | A machine-readable list of a site’s latest posts. Made for tools like this one, so it is the polite way in. |
| JSON | A plain-text data format of lists and named fields. Many public data sets, such as CISA KEV, are published as one JSON file. |
| robots.txt | A file at the top of a website saying which paths automated tools may fetch, and sometimes how slowly. |
| User-Agent | The name a program gives a website with every request. Yours is ResearchCollector/1.0. |
| Rate limit | A deliberate pause between requests to the same site, so you never overload it. |
| trafilatura | A free Python library that pulls the main text out of a web page and drops menus, ads and footers. |
| Virtual environment | A private folder of Python packages for one project (.venv). |
| CISA KEV | The US Cybersecurity and Infrastructure Security Agency’s Known Exploited Vulnerabilities catalog: flaws attackers are using in the wild. Public and free. |
Before you start. Use the Ubuntu VM from Lab 00 or Ubuntu 24.04 in WSL2 on Windows. Any Linux or Mac with Python 3.10 or newer works the same way. You need an internet connection, about 60 MB of disk and no accounts or API keys. Every command runs as your normal user (prompt $). Code goes into one file, rc.py, a piece at a time; each step ends with a quick test so you see it working before you move on.
Steps
01Make a project folder and a Python environment
A virtual environment is a private box of Python packages for one project, so nothing you install here touches the rest of your system. You need four packages: requests downloads things, feedparser reads RSS feeds, PyYAML reads the settings file, and trafilatura pulls the readable text out of a web page. Together they take about 60 MB.
$ mkdir -p ~/collector && cd ~/collector $ python3 -m venv .venv $ source .venv/bin/activate $ pip install -q requests feedparser pyyaml trafilatura $ pip list 2>/dev/null | grep -iE '^(requests|feedparser|pyyaml|trafilatura) '
feedparser 6.0.14 PyYAML 6.0.3 requests 2.34.2 trafilatura 2.3.1
If python3 -m venv fails with “ensurepip is not available”, run sudo apt install -y python3-venv once and try again. Every later step assumes you are still inside ~/collector with the environment switched on (your prompt starts with (.venv)). If you close the terminal, come back with cd ~/collector && source .venv/bin/activate.
02Tell it what to look for
Everything the collector does is driven by one settings file, config.yaml: your topics (each with the keywords that count as a mention) and your sources. This one mixes three kinds: a public data feed (CISA’s list of vulnerabilities attackers are using right now), a blog’s RSS feed, and a folder of your own notes. The block also creates one sample note.
$ mkdir -p notes $ cat > notes/week1.md <<'EOF' # Week 1: passwords and backups Turned on a password manager and passkeys for email. Set up a weekly backup of my documents to an external drive. EOF $ cat > config.yaml <<'EOF' name: My research report output_folder: output thin_threshold: 0 # topics mentioned by 0 items go on the idea list topics: # topic name: [keywords to look for] Ransomware: [ransomware] Microsoft: [microsoft, windows] Network devices: [firewall, vpn, router, cisco, fortinet, ivanti] Passwords: [password, passwords, passkey, passkeys, credential, credentials] AI: [ai, llm, chatgpt] Backups: [backup, backups] Wireless: [wi-fi, wireless, wpa3] sources: - name: CISA KEV type: cisa_kev url: https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json max_items: 50 - name: Schneier on Security type: rss url: https://www.schneier.com/feed/atom/ max_items: 5 full_text: true - name: My notes type: local_file path: notes EOF $ python -c "import yaml; c = yaml.safe_load(open('config.yaml')); print(len(c['topics']), 'topics,', len(c['sources']), 'sources')"
7 topics, 3 sources
Why topics with keywords? “Passwords” is what you care about; password, passkey and credentials are how people actually write about it. YAML is picky about spaces: keep the indentation exactly as shown. Later you will swap these topics for your own.
03Be a polite visitor: robots.txt, a name and a pause
Before your script asks any website for anything, it should do three things: read the site’s robots.txt (the file where a site says what automated tools may fetch), say who it is with a User-Agent, and wait between requests so it never hammers a server. This first piece of rc.py does all three in one function, polite_get.
$ cat > rc.py <<'EOF' """Research collector: collect, clean, store, match, report.""" import csv, hashlib, json, re, sys, time from collections import Counter from datetime import date from pathlib import Path from urllib.parse import urlparse from urllib.robotparser import RobotFileParser import feedparser, requests, trafilatura, yaml UA = "ResearchCollector/1.0" # who we say we are session = requests.Session() session.headers["User-Agent"] = UA robots, last_hit = {}, {} # remembered per website def polite_get(url, delay=3): """Fetch a URL only if robots.txt allows it, and never faster than `delay` seconds per site.""" host = "{0.scheme}://{0.netloc}".format(urlparse(url)) if host not in robots: rules = RobotFileParser() reply = session.get(host + "/robots.txt", timeout=30) if reply.status_code >= 500: rules.disallow_all = True # server trouble: treat as "no" for now elif reply.status_code >= 400: rules.allow_all = True # no robots.txt to read: no rules (RFC 9309) else: rules.parse(reply.text.splitlines()) robots[host] = rules if not robots[host].can_fetch(UA, url): print(" skipped, robots.txt says no:", url) return None wait = max(delay, robots[host].crawl_delay(UA) or 0) - (time.time() - last_hit.get(host, 0)) if wait > 0: time.sleep(wait) last_hit[host] = time.time() reply = session.get(url, timeout=60) reply.raise_for_status() return reply EOF $ python -c "import rc; r = rc.polite_get('https://www.schneier.com/feed/atom/'); print(r.status_code, len(r.content), 'bytes')" $ python -c "import rc; print(rc.polite_get('https://www.schneier.com/wp-admin/'))"
200 32048 bytes skipped, robots.txt says no: https://www.schneier.com/wp-admin/ None
Reading it. The feed came back (200 means OK). The second request was never sent: Schneier’s robots.txt disallows /wp-admin/, so polite_get refused and returned None. The rules for a site are downloaded once and remembered. If a site asks for a slower pace with Crawl-delay, the function waits that long instead. A missing robots.txt (any 4xx answer) means no rules, and a server error means “stay away for now”, as the robots.txt standard (RFC 9309) says. ResearchCollector/1.0 is a plain, honest name; never pretend to be a web browser.
04Collect and clean: a feed, a data file and your own notes
Now one small function per source type. Each turns its source into the same simple shape: source, title, link, date and text. For RSS, full_text: true in the config makes it follow each link and keep only the article itself, which is trafilatura’s job. The test at the end runs all three sources and prints the first item from each.
$ cat >> rc.py <<'EOF' def item(source, title, url, when, text): """Every source turns into the same simple shape.""" return {"source": source, "title": title.strip(), "url": url, "date": str(when or ""), "text": text.strip()} def clean_page(url): """Download an article and keep only its main text (no menus, ads or footers).""" reply = polite_get(url) return trafilatura.extract(reply.content, url=url) if reply is not None else None def from_rss(src): reply = polite_get(src["url"]) if reply is None: return [] found = [] for entry in feedparser.parse(reply.content).entries[: src.get("max_items", 10)]: t = entry.get("published_parsed") when = date(t.tm_year, t.tm_mon, t.tm_mday) if t else "" text = re.sub(r"<[^>]+>", " ", entry.get("summary", "")) # strip HTML tags if src.get("full_text"): text = clean_page(entry.link) or text found.append(item(src["name"], entry.get("title", ""), entry.link, when, text)) return found def from_cisa_kev(src): reply = polite_get(src["url"]) if reply is None: return [] vulns = sorted(reply.json()["vulnerabilities"], key=lambda v: v["dateAdded"], reverse=True) return [item(src["name"], f'{v["cveID"]}: {v["vulnerabilityName"]}', "https://nvd.nist.gov/vuln/detail/" + v["cveID"], v["dateAdded"], f'{v["vendorProject"]} {v["product"]}. {v["shortDescription"]}') for v in vulns[: src.get("max_items", 50)]] def from_local_file(src): found = [] for f in sorted(Path(src["path"]).glob("*")): if f.suffix in (".txt", ".md"): text = f.read_text(encoding="utf-8") found.append(item(src["name"], text.splitlines()[0].lstrip("# "), f.resolve().as_uri(), date.fromtimestamp(f.stat().st_mtime), text)) return found COLLECT = {"rss": from_rss, "cisa_kev": from_cisa_kev, "local_file": from_local_file} EOF $ python - <<'EOF' import rc, yaml for src in yaml.safe_load(open("config.yaml"))["sources"]: got = rc.COLLECT[src["type"]](src) first = got[0] if got else {"title": "-", "text": ""} print(f'{src["name"]}: {len(got)} items. First: {first["title"][:50]} ({len(first["text"])} chars)') EOF
CISA KEV: 50 items. First: CVE-2024-27834: Apple Multiple Products Improper A (234 chars) Schneier on Security: 5 items. First: AIs Hacking the Law (1537 chars) My notes: 1 items. First: Week 1: passwords and backups (145 chars)
Reading it. 50 entries from CISA’s Known Exploited Vulnerabilities (KEV) catalog, newest first; 5 blog posts, each cleaned down to the article text (about 1,500 characters here instead of a 100 KB web page full of menus); and your note. Your titles and counts will differ: these sources change every week. This step makes about 7 requests and takes 20 seconds or so because of the pauses. That is on purpose. If CISA answers with an error, run it again later; big sites sometimes turn away scripts from cloud servers.
05Store it: a Markdown library
Each item becomes a small Markdown file, with where it came from and when at the top (that header is called front matter). Plain files are easy to search, open in any editor, back up, or point other tools at later.
$ cat >> rc.py <<'EOF' def slug(text): return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:60] or "item" def save(it, library): """One Markdown file per item, with where it came from and when, at the top.""" folder = Path(library) / slug(it["source"]) folder.mkdir(parents=True, exist_ok=True) path = folder / (slug(it["title"]) + ".md") path.write_text( f'---\ntitle: {json.dumps(it["title"])}\nsource: {it["source"]}\nurl: {it["url"]}\n' f'date: {it["date"]}\ncollected: {date.today()}\n---\n\n# {it["title"]}\n\n{it["text"]}\n', encoding="utf-8") return path EOF $ python -c " import rc it = rc.from_local_file({'name': 'My notes', 'path': 'notes'})[0] print(rc.save(it, 'output/library')) " $ head -8 output/library/my-notes/week-1-passwords-and-backups.md
output/library/my-notes/week-1-passwords-and-backups.md --- title: "Week 1: passwords and backups" source: My notes url: file:///home/steven/collector/notes/week1.md date: 2026-11-06 collected: 2026-11-06 ---
Why keep a copy? Feeds only show the latest few entries, and pages change. A dated library with the source link lets you go back and check where an idea came from. Keep it private: it holds other people’s writing, which is fine for your own reading and not for republishing.
06Match your topics
Now the part that makes it yours: check every item against your topic list. The pattern matches whole words in any case, so a short keyword such as ai does not fire inside “said”.
$ cat >> rc.py <<'EOF' def topic_patterns(topics): """One pattern per topic. Whole words only, any case: 'ai' will not match 'said'.""" patterns = {} for name, words in topics.items(): alts = "|".join(re.escape(w) for w in (words or [name])) patterns[name] = re.compile(rf"(?<![a-z0-9])(?:{alts})(?![a-z0-9])", re.IGNORECASE) return patterns def tag(it, patterns): text = it["title"] + " " + it["text"] it["topics"] = [name for name, p in patterns.items() if p.search(text)] EOF $ python -c " import rc p = rc.topic_patterns({'AI': ['ai'], 'Backups': ['backup']}) for t in ['He said hello', 'New AI tool', 'Weekly backup done']: it = {'title': t, 'text': ''}; rc.tag(it, p); print(repr(t), '->', it['topics']) "
'He said hello' -> [] 'New AI tool' -> ['AI'] 'Weekly backup done' -> ['Backups']
Reading it. (?<![a-z0-9]) and (?![a-z0-9]) mean “no letter or digit right before or after”, which is what “whole word” means here. That is also the limit of keyword matching: backup will not match “backing up”. When a topic seems quiet, check its keywords before you trust the result.
07Write the report: ideas.md and items.csv
The last piece ties it together: collect from every source, tag topics, save to the library, remember what was seen in state.json, and write two files. ideas.md answers three questions: what’s new, what’s mentioned often, and which of your topics have little or no material. items.csv lists every item, ready for a spreadsheet.
$ cat >> rc.py <<'EOF' def write_report(cfg, items, out): new = sorted([i for i in items if i["status"] != "seen"], key=lambda i: i["date"], reverse=True) counts = Counter(t for i in items for t in i["topics"]) lines = [f'# {cfg["name"]}', "", f"{len(items)} items, {len(new)} new or changed.", "", "## What's new", ""] lines += [f'- {i["date"]} [{i["title"]}]({i["url"]}) ({i["source"]})' for i in new[:15]] \ or ["- nothing new since the last run"] lines += ["", "## Mentioned often", ""] lines += [f"- {n}: {counts[n]}" for n in sorted(cfg["topics"], key=lambda n: -counts[n])] lines += ["", "## Ideas: little or no material", ""] lines += [f"- {n}" for n in cfg["topics"] if counts[n] <= cfg.get("thin_threshold", 0)] \ or ["- every topic has material"] (out / "ideas.md").write_text("\n".join(lines) + "\n", encoding="utf-8") with open(out / "items.csv", "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["date", "source", "title", "url", "status", "topics", "file"]) for i in items: w.writerow([i["date"], i["source"], i["title"], i["url"], i["status"], "; ".join(i["topics"]), i["file"]]) def run(config_file): cfg = yaml.safe_load(Path(config_file).read_text(encoding="utf-8")) out = Path(cfg["output_folder"]) out.mkdir(parents=True, exist_ok=True) state_file = out / "state.json" # what we saw last time seen = json.loads(state_file.read_text()) if state_file.exists() else {} patterns = topic_patterns(cfg["topics"]) items = [] for src in cfg["sources"]: got = COLLECT[src["type"]](src) print(f'{src["name"]}: {len(got)} items') items += got for it in items: tag(it, patterns) key = it["source"] + "|" + it["url"] fingerprint = hashlib.sha1((it["title"] + it["text"]).encode()).hexdigest() it["status"] = "new" if key not in seen else "changed" if seen[key] != fingerprint else "seen" seen[key] = fingerprint it["file"] = str(save(it, out / "library")) state_file.write_text(json.dumps(seen, indent=0)) write_report(cfg, items, out) print("Report:", out / "ideas.md") if __name__ == "__main__": run(sys.argv[1] if len(sys.argv) > 1 else "config.yaml") EOF $ python rc.py config.yaml $ sed -n '1,12p;/## Mentioned/,$p' output/ideas.md
# My research report 56 items, 56 new or changed. ## What's new - 2026-11-06 [Week 1: passwords and backups](file:///home/steven/collector/notes/week1.md) (My notes) - 2026-11-04 [CVE-2024-27834: Apple Multiple Products Improper Authentication Vulnerability](https://nvd.nist.gov/vuln/detail/CVE-2024-27834) (CISA KEV) ... ## Mentioned often - Network devices: 9 - AI: 4 - Microsoft: 2 - Passwords: 2 - Backups: 1 - Ransomware: 0 - Wireless: 0 ## Ideas: little or no material - Ransomware - Wireless
Reading it. The “Ideas” list is the useful bit: topics on your list that none of your sources mentioned. That could mean a quiet week, a source you are missing, a gap in your notes, or keywords that need work. Each is worth a look, and that is the point of the tool: it does not decide for you, it shows you where to look. Your numbers will differ.
08Run it again: only what’s new
The first run calls everything new. Add a line to your note, run it again, and only the changed note shows up. Every item gets a fingerprint (a hash of its title and text) in state.json, so the script can tell new, changed and already-seen apart.
$ echo "Tried WPA3 on the home Wi-Fi today." >> notes/week1.md $ python rc.py config.yaml $ sed -n '1,8p' output/ideas.md $ grep -c ',seen,' output/items.csv
CISA KEV: 50 items Schneier on Security: 5 items My notes: 1 items Report: output/ideas.md # My research report 56 items, 1 new or changed. ## What's new - 2026-11-06 [Week 1: passwords and backups](file:///home/steven/collector/notes/week1.md) (My notes) 55
Reading it. 1 new or changed item, and 55 rows in items.csv marked seen. Run it once a week and “What’s new” becomes your weekly digest. Delete output/state.json to start fresh.
09Make it yours: point it at something else
Same script, different settings file. This example compares two made-up job posts with a list of skills. Paste real posts into the jobs folder (one per text file) and it works the same way.
$ mkdir -p jobs $ cat > jobs/post1.txt <<'EOF' SOC Analyst (made-up sample post) Triage alerts in a SIEM such as Splunk. Read Windows and Linux logs. PowerShell is a plus. EOF $ cat > jobs/post2.txt <<'EOF' IT Support Technician (made-up sample post) Support Windows 11 users and reset passwords. Basic SIEM and PowerShell skills are a plus. EOF $ cat > jobs.yaml <<'EOF' name: Job-skills match output_folder: output-jobs thin_threshold: 0 topics: # skills you have, or are thinking about learning Windows: [windows] Linux: [linux] PowerShell: [powershell] SIEM: [siem, splunk, sentinel] Python: [python] sources: - name: Job posts I saved type: local_file path: jobs EOF $ python rc.py jobs.yaml $ sed -n '/Mentioned/,$p' output-jobs/ideas.md
Job posts I saved: 2 items Report: output-jobs/ideas.md ## Mentioned often - Windows: 2 - PowerShell: 2 - SIEM: 2 - Linux: 1 - Python: 0 ## Ideas: little or no material - Python
A few other ways to use it. Each one is a new YAML file and, at most, a few lines of Python:
| Idea | Sources | Topics |
|---|---|---|
| Study-gap finder | Your notes folder | The objective list for the exam you are studying, one topic per objective. “Ideas” becomes your to-study list. |
| Weekly threat digest | The CISA KEV feed you already have, plus vendor security blogs by RSS | The products and attack types you look after. Run it every Monday. |
| Job-skills match | Job posts you paste into text files (this step) | Skills you have, or are thinking about learning. |
| Topic tracker | RSS feeds of the sites you already read | Whatever you are curious about this month. |
Want another source type? Write one more from_something function that returns item(...) dictionaries, add it to COLLECT, and use its name as type in the config. MITRE ATT&CK, for example, publishes its techniques as one free JSON file (with attribution) at attack-stix-data; a reader for it looks a lot like from_cisa_kev.
Reading it. Windows, PowerShell and SIEM show up in both posts; nobody asked for Python, so it lands on the idea list. With twenty real posts instead of two, that list starts to mean something. The script never changed: only the topics and sources did.
10Clean up
Switch off the environment and delete the project folder. Keep your config files somewhere else first if you want them.
$ deactivate $ cd ~ && rm -rf ~/collector $ ls ~/collector
ls: cannot access '/home/steven/collector': No such file or directory
Rather skip the setup?
We may offer a ready-to-run version of this tool later, for people who would rather not set up Python. If we do, it will be the same idea with a few extras, such as more source types and a list of words your sources keep using that are not on your topic list yet. This lab stays free, and the code you built here is yours to keep and change.
What you learned
- Polite collection — robots.txt, an honest User-Agent and a pause between requests, in one small function
- Three source types — an RSS feed, a JSON data file and local text files, all turned into the same simple shape
- Cleaning — trafilatura keeps the article and drops the rest of the page
- A library — one Markdown file per item, with the source link and dates in front matter
- Matching — whole-word regular expressions per topic, and where keyword matching falls short
- State between runs — fingerprints in a JSON file tell new, changed and seen apart
- Ideas from gaps — the topics with nothing on them are the starting points
Tried it? Tell us
Nova, our AI host, built this lab and its code from Steven’s ideas, and we ran every step once before publishing. If a step broke, behaved differently for you, or you pointed the collector at something clever, tell us. Ideas for the next lab are welcome too — no promises on timing.
NextYARA rules: write, tune and test rules that find files→
Go deeper
- Lab 00: set up your lab — the Ubuntu VM this lab assumes
- trafilatura documentation — more options for cleaning pages, including dates and metadata
- feedparser documentation — every field an RSS or Atom entry can carry
- Python urllib.robotparser and RFC 9309 — how robots.txt rules are read
- CISA Known Exploited Vulnerabilities catalog — the feed you used, with its field descriptions
- MITRE ATT&CK STIX data — a good next source to add yourself; read MITRE’s terms of use and keep the attribution
Original walkthrough. Every command was run end to end, exactly as shown, on Ubuntu 24.04 (WSL2, Python 3.12.3, requests 2.34.2, feedparser 6.0.14, PyYAML 6.0.3, trafilatura 2.3.1) on 6 Nov 2026; outputs are from that run, trimmed where marked with .... Feed contents, counts and dates change daily, so yours will differ. The sample note and job posts are made up. CISA KEV data is public; StashGrid isn’t affiliated with CISA, MITRE or any site used as an example. If something doesn’t match what your machine does, tell me and I will fix it.