Skip to content

RSS → Discord (Hugo, com estado) #19

RSS → Discord (Hugo, com estado)

RSS → Discord (Hugo, com estado) #19

name: RSS → Discord (Hugo, com estado)
on:
push:
paths:
- "content/**"
schedule:
- cron: "*/15 * * * *"
workflow_dispatch:
inputs:
force_latest:
description: "Repostar o item mais recente (ignora estado)?"
required: false
default: "false"
rss_url:
description: "Override do feed (ex.: https://cvehunters.com/index.xml)"
required: false
default: ""
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Restaura estado de IDs já postados
- name: Restore RSS state cache
id: cache
uses: actions/cache@v4
with:
path: .rss_state.json
key: rss-state-v1
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install deps
run: pip install feedparser requests
- name: Publish to Discord
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
HUGO_RSS_DEFAULT: https://cvehunters.com/index.xml
RSS_OVERRIDE: ${{ github.event.inputs.rss_url }}
FORCE_LATEST: ${{ github.event.inputs.force_latest }}
run: |
cat > rss_to_discord.py << 'PY'
import os, json, re, requests, feedparser
from html import unescape
from pathlib import Path
WEBHOOK = os.environ["DISCORD_WEBHOOK"]
RSS_URL = os.environ.get("RSS_OVERRIDE") or os.environ.get("HUGO_RSS_DEFAULT", "")
FORCE_LATEST = (os.environ.get("FORCE_LATEST","false").lower() == "true")
STATE_FILE = Path(".rss_state.json")
def load_state():
if STATE_FILE.exists():
try:
return set(json.loads(STATE_FILE.read_text()))
except Exception:
return set()
return set()
def save_state(s):
STATE_FILE.write_text(json.dumps(sorted(s)))
def entry_id(e):
return e.get("id") or e.get("guid") or e.get("link")
tagstrip_re = re.compile(r"<[^>]+>")
def clean_html(s):
s = tagstrip_re.sub("", s or "")
s = re.sub(r"\s+"," ", s).strip()
return unescape(s)
seen = load_state()
feed = feedparser.parse(RSS_URL)
print(f"[info] feed='{RSS_URL}', total entries={len(feed.entries)}, seen={len(seen)}")
to_post = []
for e in feed.entries:
eid = entry_id(e)
if not eid: # se não houver ID, usa o link como fallback
eid = e.get("link")
if not eid:
continue
if eid not in seen:
to_post.append((eid, e))
# do mais antigo para o mais novo (estável)
to_post.reverse()
posted = 0
for eid, e in to_post:
title = (e.get("title") or "Novo post").strip()
url = e.get("link","")
summary = clean_html((e.get("summary_detail") or {}).get("value") or e.get("summary") or "")
summary = summary[:250]
payload = {
"content": f"🆕 **{title}**\n{url}",
"embeds": [{"title": title, "url": url, "description": summary}],
"allowed_mentions": {"parse": []}
}
try:
r = requests.post(WEBHOOK, json=payload, timeout=20)
print(f"[post] {title} -> {r.status_code}")
r.raise_for_status()
seen.add(eid)
posted += 1
except Exception as ex:
print(f"[erro] '{title}': {ex}")
# Se nada novo e pediu force, republica o mais recente
if posted == 0 and FORCE_LATEST and feed.entries:
e = feed.entries[0]
title = (e.get("title") or "Post mais recente").strip()
url = e.get("link","")
summary = clean_html((e.get("summary_detail") or {}).get("value") or e.get("summary") or "")[:250]
payload = {
"content": f"📢 (FORÇADO) **{title}**\n{url}",
"embeds": [{"title": title, "url": url, "description": summary}],
"allowed_mentions": {"parse": []}
}
try:
r = requests.post(WEBHOOK, json=payload, timeout=20)
print(f"[force] {title} -> {r.status_code}")
r.raise_for_status()
posted = 1
except Exception as ex:
print(f"[erro] force_latest: {ex}")
save_state(seen)
print(f"[done] publicados={posted}, state_size={len(seen)}")
PY
python rss_to_discord.py
# Salva o estado atualizado no cache
- name: Save RSS state cache
if: always()
uses: actions/cache@v4
with:
path: .rss_state.json
key: rss-state-v1
restore-keys: rss-state-v1