Automated scraper that tracks Discord's Zendesk help-center articles and blog posts, detects changes, and dispatches per-change notifications via Discord webhooks. Runs hourly on GitHub Actions.
This repository uses a two-branch architecture to separate code from generated data:
| Branch | Role | Contents |
|---|---|---|
source (default) |
Code | main.py, modules/, .github/, .gitignore |
data |
Output | state.json, support/, support-dev/, support-apps/, creator-support/, support-ads/, archive/ |
The scraper runs on source (since GitHub Actions scheduled workflows only fire on the default branch), but writes its output into a checkout of data, then commits and pushes changes there. This keeps the data branch's history clean (only data commits) and the source branch's history focused on code.
GitHub Actions (source branch, cron 0 * * * *)
│
├── checkout source → ./code (scraper code + workflow)
├── checkout data → ./data (state.json + .md files)
├── pip install from code/requirements.txt
│
├── python code/main.py --scrape (OUTPUT_DIR=data, DIFF_FILE=./diff.json, MONITOR_FILE=./monitor.json)
│ ├── fetch Zendesk → 3x retry per source (backoff 1s/2s/4s), in-memory articles
│ ├── fetch Blog → 3x retry, RSS + content extraction for new/changed posts (append-only)
│ ├── health gate → if ANY source failed → save ./monitor.json, raise SystemExit(1)
│ ├── archiver → move removed .md to data/archive/{source}/, update archive/state.json (Zendesk only)
│ ├── write Zendesk → data/{source}/{id}.md + hash bodies
│ ├── write Blog → data/blog/{slug}.md + hash bodies (new/changed posts only, never deletes)
│ └── differ → diff.json (full entry objects, NOT notified yet)
│
├── commit & push data/ → data branch (skipped if scrape aborted)
│ └── capture COMMIT_SHA → $GITHUB_ENV
│
└── python code/main.py --notify --commit-sha $COMMIT_SHA (if: always(), OUTPUT_DIR=data)
├── if ./monitor.json exists → send health-failure error embed (per-source status table + Actions run link), return
├── git diff --numstat HEAD~1 HEAD → line stats (in-memory)
└── per-change Discord embeds (color-coded, Changes, commit URL field, 2s delay)
main.py ScraperEngine entrypoint (argparse: --scrape / --notify)
modules/
__init__.py
core/
__init__.py
constants.py Shared constants (ZENDESK_SOURCES, BLOG_SOURCE) + lookup_entry_by_id()
log_setup.py Loguru sink configuration (setup_logging())
monitor.py HealthMonitor — per-source status tracking + circuit breaker
processors/
__init__.py
archiver.py Archive removed articles: move .md to archive/, update archive/state.json (Zendesk only)
differ.py Diff: git status (Zendesk) + state comparison (blog)
line_stats.py Build line stats dict from git diff --numstat (in-process)
providers/
__init__.py
zendesk.py Zendesk help-center API — fetch() + write() split (raises on total failure)
blog.py Discord blog RSS + per-post content extraction → blog/{slug}.md (append-only, raises on fetch/parse failure)
notifiers/
__init__.py
discord.py Orchestrator: iterates diff, dispatches embeds + send_error() health embed, role ping on first embed (WC only), 2s delay
embeds/
__init__.py
zendesk.py create_zendesk_embed(action, entry, commit_url, source, line_stats)
blog.py create_blog_embed(action, entry, commit_url, source, line_stats)
- Scrape Zendesk — paginates through the help-center API for each source (
support,support-dev,support-apps,creator-support,support-ads), writes each article's HTML body todata/{source}/{id}.md, and stores metadata + a SHA-256 hash of the body instate.json. Each per-source fetch is wrapped in 3 retry attempts with exponential backoff (1s → 2s → 4s) and honors the HTTP429 Retry-Afterheader. A source that fails all attempts raises and is markedFAILEDin the health monitor. If a page fails mid-pagination (page 1 succeeds but a later page errors), the whole fetch raises instead of returning a partial article list, so the archiver never mistakes missing articles for removals. - Scrape Blog — fetches the Discord blog RSS feed (same 3-attempt retry wrapper). For each post not yet in
state.json(or whose title/summary/published/thumbnail changed), fetches the post page, extracts the article body from the<article class="w-richtext">element, and pretty-prints it with BeautifulSoup (body_format: 2marker) so.mdfiles are multi-line and diffs stay readable. The RSS window covers the ~100 latest posts (Webflow CMS cap); posts that fall out of the window are kept forever (append-only — the blog diff never reports removals). Failed extractions are not cached (body=None) and retried on the next run. Two anomaly guards run before any write/diff: a mass-change guard (aborts when added/updated posts exceedmax(10, 20% of tracked)) and an extraction-failure guard (aborts when failures exceedmax(5, 10% of refetch attempts)) — both raise through the health monitor so the whole run is cancelled with an error embed instead of spamming 100 webhooks. On fetch failure, markedFAILEDin the health monitor. - Health gate (circuit breaker) — after all sources are fetched,
HealthMonitor.is_healthy()is checked. If any source isFAILED, the scrape is aborted before archiving, writing, or diffing:monitor.jsonis written to the workspace root andSystemExit(1)is raised. This prevents the false-deletion cascade that occurs when a transient API outage makes the archiver treat all articles as removed. - Diff — runs
git status --porcelainin thedatacheckout to detect added (??), updated (M), and removed (D) Zendesk article files. Blog posts are diffed by comparing old vs newstate.jsonentries bylink(in-memory; the blogremovedbucket is always empty because of the append-only policy). Each diff entry carries the full object from the new state (added/updated) or old state (removed), persisted todiff.json. - Line stats — runs
git diff --numstat HEAD~1 HEADin thedatacheckout to count added/removed lines per.mdfile. Computed in-process by the notify step (no intermediate file). - Notify — if
monitor.jsonexists (aborted scrape), dispatches a single health-failure error embed to Discord listing every source's status (OK/SKIP/FAILED), article count, attempts, and error message, with a clickable link to the Actions run. Otherwise loadsdiff.jsonand dispatches one Discord embed per change (green = added, yellow = updated, red = removed). Zendesk embeds show a 2×3 grid of inline fields (Source, Article ID, Changes, Created, Promoted, Commit) plus a full-width Labels field; the Changes field (+N ~M -K) comes from the in-process line stats. Blog embeds link the title to the post, include the summary as description and the thumbnail as image. Each embed carries a clickable "View commit" field linking to the data-branch commit that captured the change. A 2-second delay separates sends to respect webhook rate limits.
A source listed in PENDING_SOURCES (modules/core/constants.py) is expected to be private/unpublished at first (e.g. support-ads, which returns HTTP 401 until Discord opens it). Such a source is still part of ZENDESK_SOURCES and gets all normal handling, but fetch failures are classified by history:
- Never scraped successfully (no entry in
state.json): the failure is reported asSKIPPED— healthy, no alert, no write. Partial results are discarded. The next run retries automatically. - Previously scraped (has state): a failure is a real regression →
FAILED, which trips the health gate, aborts the run before any write/archive (freezing the last-known.mdfiles andstate.json), and dispatches the error embed to the error webhook.
The moment a pending source returns a complete 200 response it is written like any other source, and its first articles surface as added embeds. No manual promotion is needed.
When Discord removes an article from Zendesk, the scraper preserves it instead of discarding it. Before each scrape overwrites the source directories, the archiver (modules/processors/archiver.py) identifies articles present in the previous state.json but missing from the fresh API response and:
- Moves
data/{source}/{id}.md→data/archive/{source}/{id}.md(preserving the last-known HTML body). - Appends the article's entry (metadata + body hash) to
data/archive/state.jsonunder the matching source key.
If Discord later re-publishes an archived article, the archive copy is removed and fresh content lives at data/{source}/{id}.md again — the archive keeps only the most recent removed snapshot, not historical versions.
Blog posts are exempt from archiving. The RSS feed only exposes the ~100 latest posts (Webflow CMS cap), so falling out of the RSS window does not mean a post was deleted. Blog posts are append-only: once tracked, they stay in state.json and data/blog/{slug}.md forever. Existing archive/state.json["blog"] entries from before this policy are left untouched.
The workflow (.github/workflows/scraper_workflow.yaml) runs:
- Hourly via cron (
0 * * * *) - On push to
source - Manually via workflow dispatch or repository dispatch
The workflow can be triggered on demand via the GitHub API. This is useful for running the scraper from an external scheduler (e.g. Termux cron on a phone).
Sends a repository_dispatch event with a custom event_type — ideal for scheduling from a phone or external service:
curl -s -o /dev/null \
--connect-timeout 10 \
--max-time 30 \
-L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_PAT" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/Wumpus-Central/blog-tracker/dispatches \
-d '{"event_type": "trigger-scraping"}'Alternatively, trigger the workflow directly by filename:
curl -s -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_PAT" \
https://api.github.com/repos/Wumpus-Central/blog-tracker/actions/workflows/scraper_workflow.yaml/dispatches \
-d '{"ref":"source"}'Both methods require a Personal Access Token with Actions: Write (fine-grained) or repo (classic) scope on this repository. Store the token as an environment variable (e.g. GITHUB_PAT) and schedule the curl via cron on your device.
It performs three stages:
- Scrape — runs
python code/main.py --scrapewithOUTPUT_DIR=data,DIFF_FILE=./diff.json, andMONITOR_FILE=./monitor.json. Fetches all sources (3 retries each), runs the health gate, then — if healthy — writesstate.json+.mdfiles into thedatacheckout, computes the diff, and persists it todiff.jsonin the workspace root (outsidedata/, so it is not committed). If any source failed, writesmonitor.jsonand aborts with a non-zero exit before touching data. - Commit & Push — runs
if: success()only. Commits changes indata/asgithub-actions[bot]and pushes to thedatabranch. On success, captures the commit SHA into$GITHUB_ENVasCOMMIT_SHA. Skipped automatically when the scrape aborted (non-zero exit). Also skipped if there are no changes. - Notify — runs
if: always()(so it fires even after a failed scrape). Invokespython code/main.py --notify --commit-sha $COMMIT_SHA(commit-sha omitted when no commit was produced). Ifmonitor.jsonexists, dispatches a health-failure error embed (per-source status table + link to the Actions run) and returns; otherwise loadsdiff.jsonand dispatches per-change Discord embeds with a link to the commit.
git clone -b source https://github.com/Wumpus-Central/blog-tracker.git
cd blog-tracker
python -m venv venv
./venv/bin/pip install -r requirements.txt
# Show available modes
./venv/bin/python main.py
# Scrape into a data branch checkout (writes state.json + .md + diff.json)
OUTPUT_DIR=./data ./venv/bin/python main.py --scrape
# Dispatch notifications from a previously generated diff.json
DIFF_FILE=./diff.json ./venv/bin/python main.py --notify --commit-sha <SHA>Note: The Zendesk differ relies on
git statusin the output directory, so for meaningful Zendesk diffs run--scrapeagainst a checkout of thedatabranch. Running locally withOUTPUT_DIR=.will report all Zendesk files as new (untracked). The blog differ comparesstate.jsonentries directly, so blog diffs work locally regardless of the output directory.
| Variable | Default | Mode | Description |
|---|---|---|---|
OUTPUT_DIR |
. |
both | Directory where state.json and per-source .md files are written (scrape) / where git diff --numstat runs (notify) |
DIFF_FILE |
./diff.json |
both | Path to diff.json (written by --scrape, read by --notify) |
MONITOR_FILE |
./monitor.json |
both | Path to monitor.json (written by --scrape on abort, read by --notify to send error embed). Lives outside data/, never committed. |
BLOG_MASS_CHANGE_LIMIT |
max(10, 20% of tracked posts) |
scrape | Blog guard: abort when added/updated posts exceed this value in one run. Set a higher number to allow a legit mass update. |
BLOG_EXTRACTION_FAIL_LIMIT |
max(5, 10% of refetch attempts) |
scrape | Blog guard: abort when content-extraction failures exceed this value in one run (possible HTML structure change on Discord's side). |
ACTIONS_RUN_URL |
— | notify | Optional URL to the GitHub Actions run; embedded as a clickable "View run logs" field in health-failure error embeds. Set automatically by the workflow. |
DISCORD_WEBHOOK_UNI |
— | notify | Discord webhook URL for the UNI server |
DISCORD_WEBHOOK_WUMPUSCENTRAL |
— | notify | Discord webhook URL for the official Wumpus Central server |
| Secret | Description |
|---|---|
DISCORD_WEBHOOK_UNI |
Discord webhook URL for the UNI server |
DISCORD_WEBHOOK_WUMPUSCENTRAL |
Discord webhook URL for the official Wumpus Central server |
The first embed of each notify run pings the Wumpus Central notification role; every subsequent embed in the same run is sent without a ping to avoid mention spam. The ping is a bare <@&ROLE_ID> mention placed in the webhook payload's content field before the embeds array (mentions inside embed fields do not trigger pings). The role ID is hardcoded in PING_ROLES in modules/notifiers/discord.py. UNI never receives pings, and error embeds (health-failure aborts) are dispatched to UNI only.
state.json contains all scraped data with top-level keys per source:
- Article sources (
support,support-dev,support-apps,creator-support,support-ads): arrays of article objects from the Zendesk API. Thebodyfield is replaced with a SHA-256 hash of the HTML content; the full HTML lives indata/{source}/{id}.md. blog: array of post objects built from the RSS feed + per-post page content, containingtitle,link,summary,published,media_thumbnail_url, abodySHA-256 hash, and abody_formatmarker (2= BeautifulSoup-pretty-printed HTML). The full HTML lives indata/blog/{slug}.md. Append-only: posts are never removed, even when they fall out of the RSS window.
diff.json is written by --scrape and read by --notify. It mirrors the diff structure with top-level keys per source, each containing added, updated, and removed buckets. Every entry maps its key (article id for Zendesk, post link for blog) to the full object captured at diff time — added/updated entries come from the new state, removed entries from the old state.
- Build good looking embeds for webhooks
- Rich Zendesk embeds (url, labels, created, promoted, thumbnail)
- Archive removed articles to
data/archive/(Zendesk only; blog is append-only) - Blog posts scraped as
.mdfiles with per-post content extraction - Add newsroom posts scraping as
.mdfiles - Add
WUMPUSCENTRALDiscord webhook for the official server - Centralized API notifier for reporting changes to Wumpus Central services
- Implement diff-based commit messages (new/updated/removed counts)