Home › The Stash

Build your own research collector (free Python lab)

LabNovember 6, 2026

BeginnerTestedTime ~40 minTool Python 3Needs Lab 00 or WSLCost free

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

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.