Initial commit

Hugo/PaperMod static site tracking 13 conferences and 7 journals for
  edge and cloud systems research. Includes FullCalendar deadline view,
  ICORE/SCImago rankings, DBLP paper digest pipeline, and Python
  fetch/generate scripts. PaperMod added as a git submodule.
This commit is contained in:
khannurien
2026-04-24 11:52:33 +00:00
commit 8484abea47
57 changed files with 20183 additions and 0 deletions

View File

@@ -0,0 +1,606 @@
#!/usr/bin/env python3
"""
Generate Hugo content pages from data files.
Each page has a minimal front matter block (for Hugo taxonomy / metadata) plus
a rendered Markdown body so generic themes display content without custom templates.
Reads:
site/data/venues.yaml master venue list
site/data/rankings/icore.csv ICORE conference ranks
site/data/rankings/scimago.csv SCImago journal ranks (optional)
site/data/deadlines.yaml deadline data
site/data/papers/<V>-<Y>-candidates.yaml paper pools
site/data/papers/<V>-<Y>-digest.yaml agent-curated selections
Writes:
site/content/venues/conferences/<acronym>/_index.md
site/content/venues/journals/<acronym>/_index.md
site/content/calendar/_index.md
site/content/digests/<VENUE>-<YEAR>/index.md
PRESERVED_VENUE_FIELDS are never overwritten once set (manual edits survive).
"""
import argparse
import csv
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import yaml
PRESERVED_VENUE_FIELDS = {"notes", "deadline_source"}
# Fields removed from existing front matter on every regeneration:
# - "url" reserved by Hugo (overrides page URL)
# - "papers" moved from front matter to rendered markdown body
_STRIP_FROM_EXISTING = {"url", "papers"}
# ---------------------------------------------------------------------------
# Front matter I/O
# ---------------------------------------------------------------------------
def read_hugo_file(path: Path) -> tuple[dict, str]:
if not path.exists():
return {}, ""
text = path.read_text(encoding="utf-8")
if text.startswith("---"):
parts = text.split("---", 2)
if len(parts) == 3:
return yaml.safe_load(parts[1]) or {}, parts[2]
return {}, text
def write_hugo_file(path: Path, fm: dict, body: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fm_str = yaml.dump(fm, allow_unicode=True, sort_keys=False, default_flow_style=False)
path.write_text(f"---\n{fm_str}---\n{body}", encoding="utf-8")
def merge_fm(existing: dict, new_data: dict, preserved: set[str]) -> dict:
result = {k: v for k, v in existing.items() if k not in _STRIP_FROM_EXISTING}
for key, value in new_data.items():
if key in preserved and result.get(key):
continue
result[key] = value
return result
# ---------------------------------------------------------------------------
# Data loaders
# ---------------------------------------------------------------------------
def load_icore(path: str) -> dict[str, str]:
p = Path(path)
if not p.exists():
return {}
ranks: dict[str, str] = {}
with open(p, newline="", encoding="utf-8") as f:
sample = f.read(4096); f.seek(0)
sep = ";" if sample.count(";") > sample.count(",") else ","
for row in csv.DictReader(f, delimiter=sep):
acronym = (row.get("Acronym") or row.get("acronym") or "").strip()
rank = (row.get("Rank") or row.get("rank") or row.get("CORE Rank") or "").strip()
if acronym:
ranks[acronym.upper()] = rank
return ranks
def load_scimago(path: str) -> dict[str, dict]:
p = Path(path)
if not p.exists():
return {}
journals: dict[str, dict] = {}
with open(p, newline="", encoding="utf-8") as f:
sample = f.read(4096); f.seek(0)
sep = ";" if sample.count(";") > sample.count(",") else ","
for row in csv.DictReader(f, delimiter=sep):
title = (row.get("Title") or row.get("title") or "").strip().lower()
if title:
journals[title] = {
"scimago_quartile": (row.get("SJR Best Quartile") or row.get("Best Quartile") or "").strip(),
"scimago_sjr": (row.get("SJR") or "").strip(),
"scimago_h_index": (row.get("H index") or row.get("H Index") or "").strip(),
}
return journals
def scimago_lookup(data: dict[str, dict], full_name: str, issn: str) -> dict:
entry = data.get(full_name.lower())
if entry:
return entry
needle = full_name.lower()
for title, entry in data.items():
if needle in title or title in needle:
return entry
return {}
def load_deadlines(path: str) -> dict[str, dict]:
p = Path(path)
if not p.exists():
return {}
with open(p) as f:
data = yaml.safe_load(f) or {}
return data.get("deadlines", {})
# ---------------------------------------------------------------------------
# Markdown body renderers
# ---------------------------------------------------------------------------
def _conf_body(fm: dict) -> str:
rank = fm.get("icore_rank") or ""
hp = fm.get("homepage") or ""
dl = fm.get("next_deadline") or "TBD"
abstract_dl = fm.get("abstract_deadline") or ""
event = fm.get("next_event") or ""
domains = ", ".join(f"`{d}`" for d in (fm.get("domain") or []))
notes = fm.get("notes") or ""
src = fm.get("deadline_source") or ""
lines = [f"\n*{fm.get('full_name', '')}*\n"]
lines += [
"| | |", "|---|---|",
f"| **ICORE Rank** | **{rank}** |",
f"| **Domains** | {domains} |",
]
if hp:
lines.append(f"| **Website** | [{hp}]({hp}) |")
lines += [
"\n## Upcoming Deadline\n",
"| | |", "|---|---|",
]
if abstract_dl and abstract_dl != dl:
lines.append(f"| **Abstract deadline** | {abstract_dl} |")
lines += [
f"| **Paper deadline** | {dl} |",
f"| **Event dates** | {event} |",
]
if src:
lines.append(f"| **Source** | `{src}` |")
if notes:
lines += ["\n## Notes\n", notes]
return "\n".join(lines) + "\n"
def _journal_body(fm: dict) -> str:
q = fm.get("scimago_quartile") or ""
sjr = fm.get("scimago_sjr") or ""
h = fm.get("scimago_h_index") or ""
issn = fm.get("issn") or ""
hp = fm.get("homepage") or ""
model = (fm.get("submission_model") or "rolling").title()
domains = ", ".join(f"`{d}`" for d in (fm.get("domain") or []))
notes = fm.get("notes") or ""
lines = [f"\n*{fm.get('full_name', '')}*\n"]
lines += [
"| | |", "|---|---|",
f"| **SCImago Quartile** | **{q}** |",
f"| **SJR** | {sjr} |",
f"| **H-Index** | {h} |",
f"| **ISSN** | {issn} |",
f"| **Submission model** | {model} |",
f"| **Domains** | {domains} |",
]
if hp:
lines.append(f"| **Website** | [{hp}]({hp}) |")
if notes:
lines += ["\n## Notes\n", notes]
return "\n".join(lines) + "\n"
# ---------------------------------------------------------------------------
# Date helpers
# ---------------------------------------------------------------------------
def _parse_date(s: str) -> str | None:
"""'Apr 1, 2026''2026-04-01', or None if unparseable."""
for fmt in ("%b %d, %Y", "%B %d, %Y"):
try:
return datetime.strptime(s.strip(), fmt).strftime("%Y-%m-%d")
except ValueError:
pass
return None
def _parse_date_range(s: str) -> tuple[str | None, str | None]:
"""Return (start_iso, end_iso_exclusive) from a date-range string.
Handles:
'Apr 13, 2026 - Apr 16, 2026' → ('2026-04-13', '2026-04-17')
'Nov 17-19, 2026' → ('2026-11-17', '2026-11-20')
'Apr 1, 2026' → ('2026-04-01', None)
"""
s = s.strip()
if " - " in s:
left, right = s.split(" - ", 1)
start = _parse_date(left)
end_s = _parse_date(right)
if start and end_s:
end_dt = datetime.strptime(end_s, "%Y-%m-%d") + timedelta(days=1)
return start, end_dt.strftime("%Y-%m-%d")
m = re.match(r"(\w+)\s+(\d+)-(\d+),\s+(\d{4})", s)
if m:
month, d1, d2, year = m.groups()
start = _parse_date(f"{month} {d1}, {year}")
try:
end_dt = datetime.strptime(f"{month} {d2}, {year}", "%b %d, %Y") + timedelta(days=1)
if start:
return start, end_dt.strftime("%Y-%m-%d")
except ValueError:
pass
return _parse_date(s), None
# ---------------------------------------------------------------------------
# Venue section index body renderers
# ---------------------------------------------------------------------------
_ICORE_ORDER = {"A*": 0, "A": 1, "B": 2, "C": 3}
_QUARTILE_ORDER = {"Q1": 0, "Q2": 1, "Q3": 2, "Q4": 3}
def _venues_body(venues: dict) -> str:
n_conf = len(venues.get("conferences") or [])
n_jour = len(venues.get("journals") or [])
return (
f"\nTracking **{n_conf} conferences** and **{n_jour} journals** for this domain.\n\n"
"- **[Conferences →](/venues/conferences/)** ranked by ICORE (A*, A, B, C)\n"
"- **[Journals →](/venues/journals/)** ranked by SCImago quartile (Q1Q4)\n"
)
def _conferences_section_body(venues: dict, icore: dict, deadlines: dict) -> str:
rows = []
for conf in venues.get("conferences") or []:
acronym = conf.get("acronym", "")
rank = icore.get(acronym.upper(), "") or ""
dl = deadlines.get(acronym, {})
deadline = dl.get("submission_deadline") or dl.get("abstract_deadline") or "TBD"
domains = ", ".join(f"`{d}`" for d in conf.get("domain", []))
rows.append((_ICORE_ORDER.get(rank, 99), acronym, conf.get("full_name", ""), rank, deadline, domains))
rows.sort(key=lambda r: (r[0], r[1]))
lines = ["\n| Conference | Full name | ICORE | Next deadline | Domains |",
"|---|---|---|---|---|"]
for _, acronym, full_name, rank, deadline, domains in rows:
link = f"[{acronym}](/venues/conferences/{acronym.lower()}/)"
lines.append(f"| {link} | {full_name} | **{rank}** | {deadline} | {domains} |")
return "\n".join(lines) + "\n"
def _journals_section_body(venues: dict) -> str:
rows = []
for j in venues.get("journals") or []:
acronym = j.get("acronym", "")
q = j.get("scimago_quartile") or ""
sjr = j.get("scimago_sjr") or ""
model = (j.get("submission_model") or "rolling").title()
domains = ", ".join(f"`{d}`" for d in j.get("domain", []))
rows.append((_QUARTILE_ORDER.get(q, 99), acronym, j.get("full_name", ""), q, sjr, model, domains))
rows.sort(key=lambda r: (r[0], r[1]))
lines = ["\n| Journal | Full name | SCImago | SJR | Model | Domains |",
"|---|---|---|---|---|---|"]
for _, acronym, full_name, q, sjr, model, domains in rows:
link = f"[{acronym}](/venues/journals/{acronym.lower()}/)"
lines.append(f"| {link} | {full_name} | **{q}** | {sjr} | {model} | {domains} |")
return "\n".join(lines) + "\n"
# ---------------------------------------------------------------------------
# Calendar events builder (for FullCalendar)
# ---------------------------------------------------------------------------
def _calendar_events(venues: dict, deadlines: dict) -> list[dict]:
events: list[dict] = []
for conf in venues.get("conferences") or []:
acronym = conf.get("acronym", "")
dl = deadlines.get(acronym, {})
url = f"/venues/conferences/{acronym.lower()}/"
for field, label, color in [
("abstract_deadline", "abstract deadline", "#f4a261"),
("submission_deadline", "paper deadline", "#e63946"),
]:
iso = _parse_date(dl.get(field) or "")
if iso:
events.append({"title": f"{acronym}{label}", "start": iso,
"url": url, "color": color, "allDay": True})
start, end = _parse_date_range(dl.get("event_dates") or "")
if start:
ev: dict = {"title": acronym, "start": start, "url": url,
"color": "#457b9d", "allDay": True}
if end:
ev["end"] = end
events.append(ev)
return events
def _calendar_body(venues: dict, icore: dict, deadlines: dict) -> str:
from datetime import datetime
def parse_deadline(s: str):
for fmt in ("%b %d, %Y", "%b %d %Y"):
try:
return datetime.strptime(s.strip(), fmt)
except ValueError:
pass
return datetime.max # TBD / unparseable goes last
rows = []
for conf in venues.get("conferences") or []:
acronym = conf.get("acronym", "")
rank = icore.get(acronym.upper(), "") or ""
dl_data = deadlines.get(acronym, {})
deadline = (
dl_data.get("submission_deadline")
or dl_data.get("abstract_deadline")
or "TBD"
)
event = dl_data.get("event_dates") or ""
rows.append((acronym, deadline, event, rank))
rows.sort(key=lambda r: (parse_deadline(r[1]), r[0]))
lines = [
"\n| Conference | Submission deadline | Event dates | ICORE |",
"|------------|---------------------|-------------|-------|",
]
for acronym, dl, event, rank in rows:
lines.append(f"| [{acronym}](/venues/conferences/{acronym.lower()}/) | {dl} | {event} | {rank} |")
lines += [
"",
"> Deadlines sourced from conference websites and [WikiCFP](http://www.wikicfp.com/).",
"",
]
return "\n".join(lines) + "\n"
def _digest_body(papers: list[dict]) -> str:
lines = [f"\n{len(papers)} papers selected.\n"]
for p in papers:
title = p.get("title") or "Untitled"
authors = p.get("authors") or []
author_str = (
", ".join(authors[:4]) + (" *et al.*" if len(authors) > 4 else "")
if authors else ""
)
tldr = p.get("tldr") or ""
why = p.get("why_notable") or ""
link = p.get("url") or (f"https://doi.org/{p['doi']}" if p.get("doi") else "")
lines += ["---", f"\n### {title}\n"]
if author_str:
lines.append(f"*{author_str}*\n")
if tldr:
lines.append(f"**TL;DR** — {tldr}\n")
if why:
lines.append(f"**Why notable** — {why}\n")
if link:
lines.append(f"[→ Read paper]({link})\n")
return "\n".join(lines) + "\n"
# ---------------------------------------------------------------------------
# Content generators
# ---------------------------------------------------------------------------
def gen_conferences(venues: dict, icore: dict, deadlines: dict, root: Path) -> int:
count = 0
for conf in venues.get("conferences") or []:
acronym = conf.get("acronym", "").strip()
if not acronym:
continue
path = root / "venues" / "conferences" / acronym / "_index.md"
existing_fm, _ = read_hugo_file(path)
new_fm: dict = {
"title": acronym,
"full_name": conf.get("full_name", ""),
"domain": conf.get("domain", []),
"type": "conference",
"icore_rank": icore.get(acronym.upper(), ""),
"homepage": conf.get("url", ""),
"dblp_key": conf.get("dblp_key", ""),
"draft": False,
}
dl = deadlines.get(acronym, {})
if dl.get("submission_deadline"):
new_fm["next_deadline"] = dl["submission_deadline"]
elif dl.get("abstract_deadline"):
new_fm["next_deadline"] = dl["abstract_deadline"]
if dl.get("abstract_deadline") and dl.get("submission_deadline"):
new_fm["abstract_deadline"] = dl["abstract_deadline"]
if dl.get("event_dates"):
new_fm["next_event"] = dl["event_dates"]
if dl.get("wikicfp_event_id"):
new_fm["deadline_source"] = f"wikicfp:{dl['wikicfp_event_id']}"
fm = merge_fm(existing_fm, new_fm, PRESERVED_VENUE_FIELDS)
write_hugo_file(path, fm, _conf_body(fm))
count += 1
print(f" {path.relative_to(root.parent)}", file=sys.stderr)
return count
def gen_journals(venues: dict, scimago: dict[str, dict], root: Path) -> int:
count = 0
for journal in venues.get("journals") or []:
acronym = journal.get("acronym", "").strip()
if not acronym:
continue
path = root / "venues" / "journals" / acronym / "_index.md"
existing_fm, _ = read_hugo_file(path)
inline_sjr = {
k: journal[k]
for k in ("scimago_quartile", "scimago_sjr", "scimago_h_index")
if journal.get(k)
}
sjr_data = inline_sjr or scimago_lookup(scimago, journal.get("full_name", ""), journal.get("issn", ""))
new_fm: dict = {
"title": acronym,
"full_name": journal.get("full_name", ""),
"domain": journal.get("domain", []),
"type": "journal",
"issn": journal.get("issn", ""),
"homepage": journal.get("url", ""),
"submission_model": journal.get("submission_model", "rolling"),
"dblp_key": journal.get("dblp_key", ""),
"draft": False,
**sjr_data,
}
fm = merge_fm(existing_fm, new_fm, PRESERVED_VENUE_FIELDS)
write_hugo_file(path, fm, _journal_body(fm))
count += 1
print(f" {path.relative_to(root.parent)}", file=sys.stderr)
return count
def gen_calendar(venues: dict, icore: dict, deadlines: dict, root: Path) -> None:
path = root / "calendar" / "_index.md"
existing_fm, _ = read_hugo_file(path)
new_fm = {
"title": "Submission Deadlines",
"layout": "calendar",
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"events": _calendar_events(venues, deadlines),
"draft": False,
}
fm = merge_fm(existing_fm, new_fm, set())
write_hugo_file(path, fm, _calendar_body(venues, icore, deadlines))
print(f" {path.relative_to(root.parent)}", file=sys.stderr)
def gen_section_indexes(venues: dict, icore: dict, deadlines: dict, root: Path) -> None:
# venues/_index.md
p = root / "venues" / "_index.md"
fm = merge_fm(read_hugo_file(p)[0], {"title": "Venues", "draft": False}, set())
write_hugo_file(p, fm, _venues_body(venues))
print(f" {p.relative_to(root.parent)}", file=sys.stderr)
# venues/conferences/_index.md
p = root / "venues" / "conferences" / "_index.md"
n = len(venues.get("conferences") or [])
fm = merge_fm(read_hugo_file(p)[0],
{"title": "Conferences", "description": f"{n} tracked conferences", "draft": False}, set())
write_hugo_file(p, fm, _conferences_section_body(venues, icore, deadlines))
print(f" {p.relative_to(root.parent)}", file=sys.stderr)
# venues/journals/_index.md
p = root / "venues" / "journals" / "_index.md"
n = len(venues.get("journals") or [])
fm = merge_fm(read_hugo_file(p)[0],
{"title": "Journals", "description": f"{n} tracked journals", "draft": False}, set())
write_hugo_file(p, fm, _journals_section_body(venues))
print(f" {p.relative_to(root.parent)}", file=sys.stderr)
def gen_digests(papers_dir: Path, root: Path) -> int:
if not papers_dir.exists():
return 0
count = 0
for digest_file in sorted(papers_dir.glob("*-digest.yaml")):
with open(digest_file, encoding="utf-8") as f:
digest = yaml.safe_load(f) or {}
venue = digest.get("venue", "")
year = digest.get("year")
if not venue or not year:
print(f" Skipping {digest_file.name}: missing venue or year", file=sys.stderr)
continue
# Load candidate pool for full metadata (authors, DOI, URL, etc.)
candidates: dict[str, dict] = {}
candidate_file = papers_dir / f"{venue}-{year}-candidates.yaml"
if candidate_file.exists():
with open(candidate_file, encoding="utf-8") as f:
pool = yaml.safe_load(f) or {}
for p in pool.get("papers", []):
key = p.get("dblp_key") or p.get("openalex_id") or p.get("doi") or p.get("title", "")
candidates[key] = p
# Build enriched paper list
papers_out = []
for sel in digest.get("selected", []):
key = sel.get("dblp_key") or sel.get("openalex_id") or sel.get("doi") or sel.get("title", "")
papers_out.append({**candidates.get(key, {}), **sel})
slug = f"{venue}-{year}"
path = root / "digests" / slug / "index.md"
existing_fm, _ = read_hugo_file(path)
new_fm = {
"title": f"{venue} {year} Digest",
"venue": venue,
"year": int(year),
"date": str(digest.get("date", f"{year}-01-01")),
"tags": digest.get("tags", []),
"paper_count": len(papers_out),
"draft": False,
}
fm = merge_fm(existing_fm, new_fm, set())
write_hugo_file(path, fm, _digest_body(papers_out))
count += 1
print(f" {path.relative_to(root.parent)}", file=sys.stderr)
return count
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
ap = argparse.ArgumentParser(description="Generate Hugo content from data files.")
ap.add_argument("--venues", default="site/data/venues.yaml")
ap.add_argument("--icore", default="site/data/rankings/icore.csv")
ap.add_argument("--scimago", default="site/data/rankings/scimago.csv")
ap.add_argument("--deadlines", default="site/data/deadlines.yaml")
ap.add_argument("--papers-dir", default="site/data/papers", dest="papers_dir")
ap.add_argument("--content", default="site/content")
args = ap.parse_args()
venues_path = Path(args.venues)
if not venues_path.exists():
print(f"Error: {venues_path} not found. Populate site/data/venues.yaml first.", file=sys.stderr)
sys.exit(1)
with open(venues_path, encoding="utf-8") as f:
venues = yaml.safe_load(f) or {}
content_root = Path(args.content)
icore = load_icore(args.icore)
scimago = load_scimago(args.scimago)
deadlines = load_deadlines(args.deadlines)
if icore: print(f"Loaded {len(icore)} ICORE entries", file=sys.stderr)
if scimago: print(f"Loaded {len(scimago)} SCImago entries", file=sys.stderr)
if deadlines: print(f"Loaded deadlines for {len(deadlines)} venues", file=sys.stderr)
print("\nGenerating venue section indexes …", file=sys.stderr)
gen_section_indexes(venues, icore, deadlines, content_root)
print("\nGenerating conference pages …", file=sys.stderr)
n_conf = gen_conferences(venues, icore, deadlines, content_root)
print("\nGenerating journal pages …", file=sys.stderr)
n_jour = gen_journals(venues, scimago, content_root)
print("\nGenerating calendar page …", file=sys.stderr)
gen_calendar(venues, icore, deadlines, content_root)
print("\nGenerating digest pages …", file=sys.stderr)
n_dig = gen_digests(Path(args.papers_dir), content_root)
print(f"\nDone: {n_conf} conferences, {n_jour} journals, {n_dig} digests", file=sys.stderr)
if __name__ == "__main__":
main()