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:
0
src/publish_assistant/__init__.py
Normal file
0
src/publish_assistant/__init__.py
Normal file
202
src/publish_assistant/fetch_best_papers.py
Normal file
202
src/publish_assistant/fetch_best_papers.py
Normal file
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fetch best-paper-award data from jeffhuang.com/best_paper_awards/.
|
||||
|
||||
The page lists best (and honourable mention) paper awards from ~32 CS venues
|
||||
going back to 1996. It is updated manually by the maintainer.
|
||||
|
||||
HTML layout: one <table id="YEAR"> per year; inside each table each row has
|
||||
<th class="category-name" rowspan=N> → venue name
|
||||
<td> → paper title (as <a> link)
|
||||
<td class="authors"> → author list
|
||||
|
||||
Output: site/data/best_papers.yaml (keyed by uppercase venue acronym)
|
||||
|
||||
OSDI:
|
||||
- year: 2024
|
||||
title: "Basilisk: Using Provenance Invariants ..."
|
||||
authors: ["Tony Nuda Zhang", ...]
|
||||
|
||||
Downstream use: pa-generate reads this file to annotate digest pages with
|
||||
a best-paper badge when a selected paper matches an award winner (matched
|
||||
by case-insensitive title substring).
|
||||
|
||||
Usage:
|
||||
uv run pa-fetch-best-papers [--output PATH]
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
SOURCE_URL = "https://jeffhuang.com/best_paper_awards/"
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
# Maps the anchor name from jeffhuang.com → canonical acronym used in venues.yaml.
|
||||
# The anchor appears as <a href="./conferences.html#<key>">
|
||||
VENUE_MAP: dict[str, str] = {
|
||||
"osdi": "OSDI",
|
||||
"sosp": "SOSP",
|
||||
"nsdi": "NSDI",
|
||||
"eurosys": "EuroSys",
|
||||
"usenix-atc": "ATC",
|
||||
"atc": "ATC",
|
||||
"socc": "SoCC",
|
||||
"middleware": "Middleware",
|
||||
"ipdps": "IPDPS",
|
||||
"sc": "SC",
|
||||
"hpdc": "HPDC",
|
||||
"mobisys": "MobiSys",
|
||||
"mobicom": "MobiCom",
|
||||
"sigcomm": "SIGCOMM",
|
||||
"sigmetrics": "SIGMETRICS",
|
||||
"isca": "ISCA",
|
||||
"micro": "MICRO",
|
||||
"asplos": "ASPLOS",
|
||||
"usenix-security": "USENIX Security",
|
||||
"ccs": "CCS",
|
||||
"sp": "S&P",
|
||||
"ndss": "NDSS",
|
||||
"fse": "FSE",
|
||||
"icse": "ICSE",
|
||||
"aaai": "AAAI",
|
||||
"acl": "ACL",
|
||||
"cvpr": "CVPR",
|
||||
"focs": "FOCS",
|
||||
"icml": "ICML",
|
||||
"ijcai": "IJCAI",
|
||||
"neurips": "NeurIPS",
|
||||
"sigmod": "SIGMOD",
|
||||
"vldb": "VLDB",
|
||||
"www": "WWW",
|
||||
"chi": "CHI",
|
||||
"uist": "UIST",
|
||||
"kdd": "KDD",
|
||||
"sigir": "SIGIR",
|
||||
"siggraph": "SIGGRAPH",
|
||||
"stoc": "STOC",
|
||||
"soda": "SODA",
|
||||
}
|
||||
|
||||
|
||||
def _extract_venue_key(th_tag) -> str | None:
|
||||
"""Extract the venue from a <th class='category-name'> element."""
|
||||
a = th_tag.find("a")
|
||||
if not a:
|
||||
return None
|
||||
href = a.get("href", "")
|
||||
m = re.search(r"#(.+)$", href)
|
||||
if not m:
|
||||
return None
|
||||
anchor = m.group(1).lower().strip()
|
||||
return VENUE_MAP.get(anchor)
|
||||
|
||||
|
||||
def _extract_authors(td_tag) -> list[str]:
|
||||
"""Extract author names from a <td class='authors'> element."""
|
||||
# Collect all visible text nodes (hidden extra authors are in a div.d-none)
|
||||
names: list[str] = []
|
||||
# Primary text before the et-al link
|
||||
raw = ""
|
||||
for child in td_tag.children:
|
||||
if hasattr(child, "get") and child.get("class") and "d-none" in child.get("class", []):
|
||||
# Expanded authors div
|
||||
for br_sibling in child.get_text("\n").split("\n"):
|
||||
n = br_sibling.split(",")[0].strip()
|
||||
if n and len(n) > 1:
|
||||
names.append(n)
|
||||
elif hasattr(child, "name") and child.name == "a":
|
||||
continue # skip the "et al." toggle link
|
||||
else:
|
||||
raw += getattr(child, "string", child) or ""
|
||||
# Parse primary visible text: "Name, Institution & Name2, Inst2" or "Name, Inst\nName2, Inst2"
|
||||
if raw.strip():
|
||||
for segment in re.split(r"[;&\n]|(?:\s*,\s*(?=[A-Z]))", raw):
|
||||
n = segment.split(",")[0].strip()
|
||||
if n and len(n) > 2 and not re.match(r"^(University|Institute|School|Dept|Lab|MIT|ETH|CMU|IBM|Google|Meta|Microsoft|Amazon|Apple|Intel|NVIDIA)", n):
|
||||
names.append(n)
|
||||
return names[:8] # cap at 8 to keep YAML manageable
|
||||
|
||||
|
||||
def fetch_best_papers() -> dict[str, list[dict]]:
|
||||
"""Return a dict keyed by venue acronym; each value is a list of award dicts."""
|
||||
r = requests.get(SOURCE_URL, headers=HEADERS, timeout=30)
|
||||
r.raise_for_status()
|
||||
soup = BeautifulSoup(r.text, "lxml")
|
||||
|
||||
results: dict[str, list[dict]] = {}
|
||||
|
||||
# Each year is a <table id="YYYY">
|
||||
for table in soup.find_all("table"):
|
||||
table_id = table.get("id", "")
|
||||
if not re.fullmatch(r"\d{4}", table_id):
|
||||
continue
|
||||
year = int(table_id)
|
||||
|
||||
current_venue: str | None = None
|
||||
|
||||
for row in table.find_all("tr"):
|
||||
# Category header cell updates the current venue
|
||||
cat_th = row.find("th", class_="category-name")
|
||||
if cat_th:
|
||||
current_venue = _extract_venue_key(cat_th)
|
||||
|
||||
if not current_venue:
|
||||
continue
|
||||
|
||||
# Paper title cell
|
||||
tds = row.find_all("td")
|
||||
if not tds:
|
||||
continue
|
||||
title_td = tds[0]
|
||||
link = title_td.find("a")
|
||||
if not link:
|
||||
continue
|
||||
title = link.get_text(strip=True)
|
||||
if len(title) < 8:
|
||||
continue
|
||||
|
||||
# Authors cell
|
||||
authors: list[str] = []
|
||||
if len(tds) >= 2:
|
||||
authors = _extract_authors(tds[1])
|
||||
|
||||
results.setdefault(current_venue, []).append({
|
||||
"year": year,
|
||||
"title": title,
|
||||
"authors": authors,
|
||||
"source": SOURCE_URL,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Fetch best-paper awards from jeffhuang.com.")
|
||||
ap.add_argument("--output", default="site/data/best_papers.yaml")
|
||||
args = ap.parse_args()
|
||||
|
||||
print(f"Fetching {SOURCE_URL} …", file=sys.stderr)
|
||||
data = fetch_best_papers()
|
||||
|
||||
total = sum(len(v) for v in data.values())
|
||||
print(f"Found {total} award entries across {len(data)} venues", file=sys.stderr)
|
||||
for venue, entries in sorted(data.items()):
|
||||
print(f" {venue}: {len(entries)}", file=sys.stderr)
|
||||
|
||||
out = Path(args.output)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, allow_unicode=True, sort_keys=True, default_flow_style=False)
|
||||
print(f"Written → {out}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
232
src/publish_assistant/fetch_deadlines.py
Normal file
232
src/publish_assistant/fetch_deadlines.py
Normal file
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fetch submission deadlines for tracked conferences from WikiCFP.
|
||||
|
||||
For each conference in data/venues.yaml, this script:
|
||||
1. Searches WikiCFP by acronym (or uses wikicfp_series if provided)
|
||||
2. Scrapes the matching CFP page for deadline dates
|
||||
3. Writes results to data/deadlines.yaml
|
||||
|
||||
Conferences where no CFP was found are listed under the 'missing' key in the
|
||||
output YAML — the agent should fill those in manually (Task 2 in README).
|
||||
|
||||
Usage:
|
||||
python fetch_deadlines.py [--venues PATH] [--output PATH]
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
WIKICFP_SEARCH = "http://www.wikicfp.com/cfp/servlet/tool.search"
|
||||
WIKICFP_EVENT = "http://www.wikicfp.com/cfp/servlet/event.showcfp"
|
||||
WIKICFP_SERIES = "http://www.wikicfp.com/cfp/program?id={series_id}"
|
||||
HEADERS = {
|
||||
"User-Agent": "publish-assistant/1.0 (academic research tool)",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
REQUEST_DELAY = 2.5
|
||||
|
||||
|
||||
def search_wikicfp(session: requests.Session, query: str) -> list[dict]:
|
||||
"""Search WikiCFP for a query string. Returns a list of CFP summary dicts."""
|
||||
r = session.get(
|
||||
WIKICFP_SEARCH,
|
||||
params={"q": query, "year": "f"},
|
||||
headers=HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
soup = BeautifulSoup(r.text, "lxml")
|
||||
results = []
|
||||
|
||||
# WikiCFP search results use paired rows: [name+link | when/where] [desc]
|
||||
contsec = soup.find("div", class_="contsec")
|
||||
if not contsec:
|
||||
return results
|
||||
|
||||
rows = contsec.find_all("tr")
|
||||
i = 0
|
||||
while i < len(rows):
|
||||
cells = rows[i].find_all("td")
|
||||
# First row of a pair has 4 cells: title, acronym, deadline, event-date
|
||||
if len(cells) >= 3:
|
||||
link = cells[0].find("a", href=re.compile(r"showcfp\?eventid="))
|
||||
if link:
|
||||
m = re.search(r"eventid=(\d+)", link["href"])
|
||||
event_id = m.group(1) if m else None
|
||||
results.append({
|
||||
"event_id": event_id,
|
||||
"title": cells[0].get_text(strip=True),
|
||||
"acronym": cells[1].get_text(strip=True) if len(cells) > 1 else "",
|
||||
"deadline": cells[2].get_text(strip=True) if len(cells) > 2 else "",
|
||||
"event_dates": cells[3].get_text(strip=True) if len(cells) > 3 else "",
|
||||
})
|
||||
i += 1
|
||||
return results
|
||||
|
||||
|
||||
def fetch_cfp_details(session: requests.Session, event_id: str) -> dict:
|
||||
"""Scrape a WikiCFP event page for structured deadline information."""
|
||||
r = session.get(
|
||||
WIKICFP_EVENT,
|
||||
params={"eventid": event_id},
|
||||
headers=HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
soup = BeautifulSoup(r.text, "lxml")
|
||||
|
||||
details: dict = {"wikicfp_event_id": event_id, "source": "wikicfp"}
|
||||
|
||||
# WikiCFP event pages use <th> for labels and <td> for values in the same <tr>
|
||||
for row in soup.find_all("tr"):
|
||||
th = row.find("th", recursive=False)
|
||||
td = row.find("td", recursive=False)
|
||||
if not (th and td):
|
||||
continue
|
||||
label = th.get_text(strip=True).lower()
|
||||
value = td.get_text(" ", strip=True)
|
||||
if not value or value in ("N/A", "TBD"):
|
||||
continue
|
||||
if "abstract" in label:
|
||||
details["abstract_deadline"] = value
|
||||
elif "submission" in label or "paper" in label:
|
||||
details["submission_deadline"] = value
|
||||
elif "notification" in label:
|
||||
details["notification"] = value
|
||||
elif "camera" in label or "final" in label:
|
||||
details["camera_ready"] = value
|
||||
elif "when" in label:
|
||||
details["event_dates"] = value
|
||||
elif "where" in label or "location" in label:
|
||||
details["location"] = value
|
||||
|
||||
# Grab CFP link from the page header area
|
||||
cfp_link = soup.find("a", href=re.compile(r"^https?://"), string=re.compile(r"cfp|call|submit", re.I))
|
||||
if cfp_link and "cfp_url" not in details:
|
||||
details["cfp_url"] = cfp_link["href"]
|
||||
|
||||
return details
|
||||
|
||||
|
||||
def best_match(results: list[dict], acronym: str) -> dict | None:
|
||||
"""Return the most likely match for a conference acronym from search results."""
|
||||
acronym_up = acronym.upper()
|
||||
# Require the acronym to appear as a standalone token (e.g. "SOSP 2026", not "EESP-SC")
|
||||
pattern = re.compile(r"(?<![A-Z-])" + re.escape(acronym_up) + r"(?![A-Z])")
|
||||
for r in results:
|
||||
if pattern.search(r["title"].upper()):
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def process_venue(session: requests.Session, conf: dict) -> dict | None:
|
||||
"""Return deadline data for a conference, or None if not found."""
|
||||
acronym = conf.get("acronym", "")
|
||||
wikicfp_id = conf.get("wikicfp_id")
|
||||
|
||||
# wikicfp_id: false means explicitly not on WikiCFP (skip search)
|
||||
if wikicfp_id is False:
|
||||
return None
|
||||
|
||||
# wikicfp_id: "<number>" means use that event page directly
|
||||
if wikicfp_id:
|
||||
details = fetch_cfp_details(session, str(wikicfp_id))
|
||||
details["wikicfp_title"] = acronym
|
||||
return details
|
||||
|
||||
results = search_wikicfp(session, acronym)
|
||||
match = best_match(results, acronym)
|
||||
if not match or not match.get("event_id"):
|
||||
return None
|
||||
|
||||
time.sleep(REQUEST_DELAY)
|
||||
details = fetch_cfp_details(session, match["event_id"])
|
||||
details["wikicfp_title"] = match.get("title", "")
|
||||
return details
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Fetch submission deadlines from WikiCFP.")
|
||||
ap.add_argument("--venues", default="site/data/venues.yaml")
|
||||
ap.add_argument("--output", default="site/data/deadlines.yaml")
|
||||
args = ap.parse_args()
|
||||
|
||||
venues_path = Path(args.venues)
|
||||
if not venues_path.exists():
|
||||
print(f"Error: {venues_path} not found. Populate data/venues.yaml first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with open(venues_path) as f:
|
||||
venues = yaml.safe_load(f) or {}
|
||||
|
||||
conferences = venues.get("conferences") or []
|
||||
if not conferences:
|
||||
print("No conferences in venues.yaml. Nothing to do.", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
# Preserve any manually-entered deadlines from the previous output
|
||||
existing: dict[str, dict] = {}
|
||||
out_path = Path(args.output)
|
||||
if out_path.exists():
|
||||
with open(out_path) as f:
|
||||
prev = yaml.safe_load(f) or {}
|
||||
for acronym, data in (prev.get("deadlines") or {}).items():
|
||||
if (data or {}).get("source") == "manual":
|
||||
existing[acronym] = data
|
||||
|
||||
session = requests.Session()
|
||||
found: dict[str, dict] = dict(existing) # start with manual entries
|
||||
missing: list[str] = []
|
||||
|
||||
for conf in conferences:
|
||||
acronym = conf.get("acronym", "")
|
||||
print(f" {acronym} …", file=sys.stderr)
|
||||
if acronym in existing:
|
||||
print(f" manual entry preserved", file=sys.stderr)
|
||||
continue
|
||||
try:
|
||||
time.sleep(REQUEST_DELAY)
|
||||
data = process_venue(session, conf)
|
||||
if data:
|
||||
found[acronym] = data
|
||||
dl = data.get("submission_deadline") or data.get("abstract_deadline") or "?"
|
||||
print(f" deadline: {dl}", file=sys.stderr)
|
||||
else:
|
||||
missing.append(acronym)
|
||||
print(f" not found on WikiCFP", file=sys.stderr)
|
||||
except Exception as exc:
|
||||
missing.append(acronym)
|
||||
print(f" error: {exc}", file=sys.stderr)
|
||||
|
||||
output = {
|
||||
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"deadlines": found,
|
||||
"missing": missing,
|
||||
}
|
||||
|
||||
out_path = Path(args.output)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(out_path, "w") as f:
|
||||
yaml.dump(output, f, allow_unicode=True, sort_keys=False, default_flow_style=False)
|
||||
|
||||
print(
|
||||
f"\nResults: {len(found)} found, {len(missing)} missing → {out_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if missing:
|
||||
print(
|
||||
f"Missing (fill in manually — see README Task 2): {', '.join(missing)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
143
src/publish_assistant/fetch_icore.py
Normal file
143
src/publish_assistant/fetch_icore.py
Normal file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fetch ICORE conference rankings from portal.core.edu.au and save to CSV.
|
||||
|
||||
Scrapes the portal's HTML search results with pagination. Results are merged
|
||||
with any existing CSV so manual overrides survive.
|
||||
|
||||
Usage:
|
||||
python fetch_icore.py [--query QUERY] [--source SOURCE] [--output PATH]
|
||||
|
||||
Examples:
|
||||
# Fetch all rankings for the current default source
|
||||
python fetch_icore.py --output data/rankings/icore.csv
|
||||
|
||||
# Fetch rankings matching a keyword
|
||||
python fetch_icore.py --query "distributed" --output data/rankings/icore.csv
|
||||
|
||||
# Use an older ranking round
|
||||
python fetch_icore.py --source CORE2021 --output data/rankings/icore_2021.csv
|
||||
|
||||
Available sources (as of 2024): CORE2023, CORE2021, CORE2020, CORE2018, CORE2017
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
BASE_URL = "https://portal.core.edu.au/conf-ranks/"
|
||||
HEADERS = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0"}
|
||||
REQUEST_DELAY = 1.5 # seconds between paginated requests
|
||||
|
||||
|
||||
def fetch_page(session: requests.Session, query: str, source: str, page: int) -> BeautifulSoup:
|
||||
r = session.get(
|
||||
BASE_URL,
|
||||
params={"search": query, "by": "all", "source": source, "sort": "arank", "page": page},
|
||||
headers=HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return BeautifulSoup(r.text, "lxml")
|
||||
|
||||
|
||||
def parse_rows(soup: BeautifulSoup) -> list[dict]:
|
||||
table = soup.find("table")
|
||||
if not table:
|
||||
return []
|
||||
headers = [th.get_text(strip=True) for th in table.find_all("th")]
|
||||
if not headers:
|
||||
return []
|
||||
rows = []
|
||||
for tr in table.find_all("tr"):
|
||||
cells = [td.get_text(strip=True) for td in tr.find_all("td")]
|
||||
if len(cells) == len(headers):
|
||||
rows.append(dict(zip(headers, cells)))
|
||||
return rows
|
||||
|
||||
|
||||
def get_total_pages(soup: BeautifulSoup) -> int:
|
||||
import re
|
||||
# Portal uses javascript: jumpPage('N') links for pagination
|
||||
max_page = 1
|
||||
for a in soup.find_all("a", href=re.compile(r"jumpPage")):
|
||||
m = re.search(r"jumpPage\('?(\d+)'?\)", a.get("href", ""))
|
||||
if m:
|
||||
max_page = max(max_page, int(m.group(1)))
|
||||
if max_page > 1:
|
||||
return max_page
|
||||
# Fallback: "Page X of Y" text
|
||||
for s in soup.stripped_strings:
|
||||
parts = s.strip().split()
|
||||
if len(parts) >= 4 and parts[0] == "Page" and parts[2] == "of":
|
||||
try:
|
||||
return int(parts[3])
|
||||
except ValueError:
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
def fetch_all(query: str, source: str) -> list[dict]:
|
||||
session = requests.Session()
|
||||
print(f"Fetching ICORE rankings (source={source}, query={query!r}) …", file=sys.stderr)
|
||||
first_page = fetch_page(session, query, source, 1)
|
||||
rows = parse_rows(first_page)
|
||||
total = get_total_pages(first_page)
|
||||
print(f" {total} page(s) found", file=sys.stderr)
|
||||
for page in range(2, total + 1):
|
||||
print(f" page {page}/{total} …", file=sys.stderr)
|
||||
time.sleep(REQUEST_DELAY)
|
||||
rows.extend(parse_rows(fetch_page(session, query, source, page)))
|
||||
return rows
|
||||
|
||||
|
||||
def merge_and_write(new_rows: list[dict], output_path: Path) -> None:
|
||||
"""Merge new_rows with existing CSV, keyed on Acronym. New data wins."""
|
||||
existing: dict[str, dict] = {}
|
||||
if output_path.exists():
|
||||
with open(output_path, newline="", encoding="utf-8") as f:
|
||||
for row in csv.DictReader(f):
|
||||
key = row.get("Acronym") or row.get("acronym") or ""
|
||||
existing[key] = row
|
||||
|
||||
for row in new_rows:
|
||||
key = row.get("Acronym") or row.get("acronym") or ""
|
||||
existing[key] = row
|
||||
|
||||
if not existing:
|
||||
print("No data to write.", file=sys.stderr)
|
||||
return
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fieldnames = list(next(iter(existing.values())).keys())
|
||||
with open(output_path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(existing.values())
|
||||
print(f"Saved {len(existing)} entries → {output_path}", file=sys.stderr)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Fetch ICORE conference rankings.")
|
||||
ap.add_argument("--query", default="", help="Search term (default: all)")
|
||||
ap.add_argument("--source", default="CORE2023", help="Ranking source (default: CORE2023)")
|
||||
ap.add_argument("--output", default="site/data/rankings/icore.csv", help="Output CSV path")
|
||||
args = ap.parse_args()
|
||||
|
||||
rows = fetch_all(args.query, args.source)
|
||||
if not rows:
|
||||
print(
|
||||
"No rows returned. The portal may have changed its HTML structure, "
|
||||
"or the search returned no results.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
merge_and_write(rows, Path(args.output))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
256
src/publish_assistant/fetch_papers.py
Normal file
256
src/publish_assistant/fetch_papers.py
Normal file
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fetch paper metadata for a conference or journal by venue acronym and year.
|
||||
|
||||
Primary source: DBLP (most reliable for CS venues).
|
||||
Fallback: OpenAlex (broader coverage, includes citation counts).
|
||||
|
||||
Output is a YAML candidate pool at data/papers/<VENUE>-<YEAR>-candidates.yaml.
|
||||
The agent then selects notable papers from this pool and writes a digest YAML
|
||||
(data/papers/<VENUE>-<YEAR>-digest.yaml) — see README Task 3.
|
||||
|
||||
Usage:
|
||||
python fetch_papers.py --venue OSDI --year 2025
|
||||
python fetch_papers.py --venue OSDI --year 2025 --dblp-key conf/osdi
|
||||
python fetch_papers.py --venue TPDS --year 2024 --source openalex
|
||||
|
||||
Tips:
|
||||
- If DBLP auto-detection fails, look up the key at https://dblp.org/db/conf/
|
||||
or https://dblp.org/db/journals/ and pass it with --dblp-key.
|
||||
- For journals, the DBLP key is usually like "journals/tpds".
|
||||
- For conferences, it is usually like "conf/osdi" or "conf/eurosys".
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
DBLP_SEARCH_URL = "https://dblp.org/search/publ/api"
|
||||
DBLP_VENUE_URL = "https://dblp.org/search/venue/api"
|
||||
OPENALEX_WORKS_URL = "https://api.openalex.org/works"
|
||||
OPENALEX_VENUES_URL = "https://api.openalex.org/venues"
|
||||
HEADERS = {
|
||||
"User-Agent": "publish-assistant/1.0 (academic research tool; mailto:research@example.org)"
|
||||
}
|
||||
REQUEST_DELAY = 1.0
|
||||
|
||||
|
||||
# --- DBLP helpers ---
|
||||
|
||||
def dblp_find_venue_key(acronym: str) -> str | None:
|
||||
"""Search DBLP for a venue and return its stream key (e.g. 'conf/osdi')."""
|
||||
r = requests.get(
|
||||
DBLP_VENUE_URL,
|
||||
params={"q": acronym, "format": "json", "h": 20},
|
||||
headers=HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
hits = r.json().get("result", {}).get("hits", {}).get("hit", []) or []
|
||||
acronym_up = acronym.upper()
|
||||
for hit in hits:
|
||||
info = hit.get("info", {})
|
||||
url = info.get("url", "")
|
||||
name = info.get("venue", "")
|
||||
# Match acronym in the venue URL path segment or venue name
|
||||
if acronym_up in name.upper() or f"/{acronym.lower()}/" in url.lower() or url.lower().endswith(f"/{acronym.lower()}"):
|
||||
if "/db/" in url:
|
||||
return url.split("/db/")[-1].rstrip("/")
|
||||
return None
|
||||
|
||||
|
||||
def dblp_fetch_papers(venue_key: str, year: int, max_results: int = 500) -> list[dict]:
|
||||
"""Fetch papers from DBLP for a venue key and year using the search API."""
|
||||
papers: list[dict] = []
|
||||
batch = 250
|
||||
offset = 0
|
||||
query = f"streamid:{venue_key}: year:{year}"
|
||||
|
||||
while len(papers) < max_results:
|
||||
r = requests.get(
|
||||
DBLP_SEARCH_URL,
|
||||
params={"q": query, "format": "json", "h": batch, "f": offset},
|
||||
headers=HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
result = r.json().get("result", {})
|
||||
hits = result.get("hits", {}).get("hit", []) or []
|
||||
if not hits:
|
||||
break
|
||||
for hit in hits:
|
||||
info = hit.get("info", {})
|
||||
# Authors may be a string, dict, or list depending on count
|
||||
raw_authors = info.get("authors", {}).get("author", []) or []
|
||||
if isinstance(raw_authors, str):
|
||||
authors = [raw_authors]
|
||||
elif isinstance(raw_authors, dict):
|
||||
authors = [raw_authors.get("text", "")]
|
||||
else:
|
||||
authors = [
|
||||
a.get("text", a) if isinstance(a, dict) else str(a)
|
||||
for a in raw_authors
|
||||
]
|
||||
papers.append({
|
||||
"title": (info.get("title") or "").rstrip("."),
|
||||
"authors": authors,
|
||||
"year": info.get("year"),
|
||||
"doi": info.get("doi"),
|
||||
"url": info.get("ee") or info.get("url"),
|
||||
"dblp_key": info.get("key"),
|
||||
"venue_name": info.get("venue"),
|
||||
"pages": info.get("pages"),
|
||||
})
|
||||
offset += len(hits)
|
||||
if len(hits) < batch:
|
||||
break
|
||||
time.sleep(REQUEST_DELAY)
|
||||
|
||||
return papers
|
||||
|
||||
|
||||
# --- OpenAlex helpers ---
|
||||
|
||||
def openalex_find_venue_id(name: str) -> str | None:
|
||||
"""Find an OpenAlex venue/source ID by display name."""
|
||||
r = requests.get(
|
||||
OPENALEX_VENUES_URL,
|
||||
params={"search": name, "per_page": 5},
|
||||
headers=HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
results = r.json().get("results", [])
|
||||
return results[0]["id"] if results else None
|
||||
|
||||
|
||||
def openalex_fetch_papers(venue_name: str, year: int, max_results: int = 500) -> list[dict]:
|
||||
"""Fetch papers from OpenAlex for a venue name and year."""
|
||||
venue_id = openalex_find_venue_id(venue_name)
|
||||
if not venue_id:
|
||||
print(f" OpenAlex: no venue found for {venue_name!r}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
papers: list[dict] = []
|
||||
page = 1
|
||||
while len(papers) < max_results:
|
||||
r = requests.get(
|
||||
OPENALEX_WORKS_URL,
|
||||
params={
|
||||
"filter": f"primary_location.source.id:{venue_id},publication_year:{year}",
|
||||
"per_page": 200,
|
||||
"page": page,
|
||||
"mailto": "research@example.org",
|
||||
},
|
||||
headers=HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
results = r.json().get("results", [])
|
||||
if not results:
|
||||
break
|
||||
for work in results:
|
||||
authors = [
|
||||
a.get("author", {}).get("display_name", "")
|
||||
for a in work.get("authorships", [])
|
||||
]
|
||||
oa = work.get("open_access", {})
|
||||
papers.append({
|
||||
"title": work.get("title", ""),
|
||||
"authors": authors,
|
||||
"year": work.get("publication_year"),
|
||||
"doi": work.get("doi"),
|
||||
"url": work.get("primary_location", {}).get("landing_page_url"),
|
||||
"open_access_url": oa.get("oa_url"),
|
||||
"cited_by_count": work.get("cited_by_count", 0),
|
||||
"openalex_id": work.get("id"),
|
||||
})
|
||||
if len(results) < 200:
|
||||
break
|
||||
page += 1
|
||||
time.sleep(REQUEST_DELAY)
|
||||
|
||||
return papers
|
||||
|
||||
|
||||
# --- Main ---
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Fetch paper candidates for a venue and year.")
|
||||
ap.add_argument("--venue", required=True, help="Venue acronym (e.g. OSDI, EuroSys, TPDS)")
|
||||
ap.add_argument("--year", required=True, type=int)
|
||||
ap.add_argument(
|
||||
"--output",
|
||||
help="Output path (default: data/papers/<VENUE>-<YEAR>-candidates.yaml)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--source",
|
||||
choices=["dblp", "openalex", "auto"],
|
||||
default="auto",
|
||||
help="Data source (default: auto — tries DBLP first, then OpenAlex)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--dblp-key",
|
||||
help="Override DBLP venue key (e.g. conf/osdi). Use when auto-detection fails.",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--max", type=int, default=500, dest="max_results", help="Max papers to fetch (default: 500)"
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
out_path = Path(args.output or f"site/data/papers/{args.venue}-{args.year}-candidates.yaml")
|
||||
|
||||
papers: list[dict] = []
|
||||
source_used = args.source
|
||||
|
||||
if args.source in ("dblp", "auto"):
|
||||
venue_key = args.dblp_key
|
||||
if not venue_key:
|
||||
print(f"Looking up DBLP key for {args.venue} …", file=sys.stderr)
|
||||
venue_key = dblp_find_venue_key(args.venue)
|
||||
|
||||
if venue_key:
|
||||
print(f"Fetching from DBLP (key: {venue_key}) …", file=sys.stderr)
|
||||
papers = dblp_fetch_papers(venue_key, args.year, args.max_results)
|
||||
source_used = "dblp"
|
||||
else:
|
||||
print(
|
||||
f"Could not find DBLP key for {args.venue!r}. "
|
||||
"Try --dblp-key or --source openalex.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if not papers and args.source in ("openalex", "auto"):
|
||||
print(f"Fetching from OpenAlex …", file=sys.stderr)
|
||||
papers = openalex_fetch_papers(args.venue, args.year, args.max_results)
|
||||
source_used = "openalex"
|
||||
|
||||
if not papers:
|
||||
print(
|
||||
"No papers found. "
|
||||
"Check the acronym, try --dblp-key, or use --source openalex.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
output = {
|
||||
"venue": args.venue,
|
||||
"year": args.year,
|
||||
"source": source_used,
|
||||
"count": len(papers),
|
||||
"papers": papers,
|
||||
}
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(output, f, allow_unicode=True, sort_keys=False, default_flow_style=False)
|
||||
|
||||
print(f"Saved {len(papers)} papers → {out_path}", file=sys.stderr)
|
||||
print("Next step: review the candidates and create a digest YAML (see README Task 3).", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
117
src/publish_assistant/fetch_scimago.py
Normal file
117
src/publish_assistant/fetch_scimago.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Download SCImago journal rankings as CSV.
|
||||
|
||||
SCImago publishes rankings by subject area. This script downloads the CSV for
|
||||
a given area code and year. Use --list-areas to see available CS area codes.
|
||||
|
||||
Usage:
|
||||
python fetch_scimago.py [--area CODE] [--year YEAR] [--output PATH]
|
||||
python fetch_scimago.py --list-areas
|
||||
|
||||
Examples:
|
||||
# Fetch all CS journals (area 1700), latest available year
|
||||
python fetch_scimago.py --output data/rankings/scimago.csv
|
||||
|
||||
# Fetch only "Computer Networks and Communications" journals
|
||||
python fetch_scimago.py --area 1705 --output data/rankings/scimago_networks.csv
|
||||
|
||||
# Fetch a specific year
|
||||
python fetch_scimago.py --area 1700 --year 2022 --output data/rankings/scimago_2022.csv
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
SCIMAGO_URL = "https://www.scimagojr.com/journalrank.php"
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Referer": "https://www.scimagojr.com/journalrank.php",
|
||||
}
|
||||
|
||||
# Subject area codes for Computer Science and related fields
|
||||
CS_AREAS: dict[str, str] = {
|
||||
"1700": "Computer Science (general)",
|
||||
"1701": "Computer Science Applications",
|
||||
"1702": "Artificial Intelligence",
|
||||
"1703": "Computational Theory and Mathematics",
|
||||
"1704": "Computer Graphics and Computer-Aided Design",
|
||||
"1705": "Computer Networks and Communications",
|
||||
"1706": "Computer Science Miscellaneous",
|
||||
"1707": "Computer Vision and Pattern Recognition",
|
||||
"1708": "Hardware and Architecture",
|
||||
"1709": "Human-Computer Interaction",
|
||||
"1710": "Information Systems",
|
||||
"1711": "Signal Processing",
|
||||
"2200": "Engineering (general)",
|
||||
"2202": "Aerospace Engineering",
|
||||
"2208": "Electrical and Electronic Engineering",
|
||||
}
|
||||
|
||||
|
||||
def list_areas() -> None:
|
||||
print("SCImago subject area codes (Computer Science / Engineering):\n")
|
||||
for code, name in CS_AREAS.items():
|
||||
print(f" {code} {name}")
|
||||
|
||||
|
||||
def download_csv(area: str, year: str) -> str:
|
||||
params = {
|
||||
"area": area,
|
||||
"category": 0,
|
||||
"country": "all",
|
||||
"year": year,
|
||||
"order": "sjr",
|
||||
"min": 0,
|
||||
"min_type": "cd",
|
||||
"type": "j",
|
||||
"format": "csv",
|
||||
}
|
||||
label = CS_AREAS.get(str(area), area)
|
||||
print(f"Downloading SCImago CSV: area={area} ({label}), year={year} …", file=sys.stderr)
|
||||
session = requests.Session()
|
||||
# First visit the page to acquire a session cookie
|
||||
session.get(SCIMAGO_URL, headers=HEADERS, timeout=30)
|
||||
r = session.get(SCIMAGO_URL, params=params, headers=HEADERS, timeout=60)
|
||||
r.raise_for_status()
|
||||
# SCImago may return an HTML anti-bot challenge instead of CSV
|
||||
text = r.text.lstrip("")
|
||||
if text.lstrip().startswith("<!"):
|
||||
raise ValueError(
|
||||
"SCImago returned an HTML page instead of CSV — likely blocked by anti-bot "
|
||||
"protection. Download the CSV manually from scimagojr.com (Journal Rankings → "
|
||||
"choose area → Download) and save it to site/data/rankings/scimago.csv.\n"
|
||||
"Alternatively, add scimago_quartile / scimago_sjr fields directly to "
|
||||
"site/data/venues.yaml (see existing journal entries for the format)."
|
||||
)
|
||||
if not text.strip():
|
||||
raise ValueError("Empty response from SCImago. The area code or year may be invalid.")
|
||||
return text
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Download SCImago journal rankings.")
|
||||
ap.add_argument("--area", default="1700", help="Subject area code (default: 1700 — all CS)")
|
||||
ap.add_argument("--year", default="2023", help="Ranking year (default: 2023)")
|
||||
ap.add_argument("--output", default="site/data/rankings/scimago.csv")
|
||||
ap.add_argument("--list-areas", action="store_true", help="Print available area codes and exit")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.list_areas:
|
||||
list_areas()
|
||||
return
|
||||
|
||||
csv_text = download_csv(args.area, args.year)
|
||||
out = Path(args.output)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(csv_text, encoding="utf-8")
|
||||
n = csv_text.count("\n") - 1
|
||||
print(f"Saved {n} entries → {out}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
606
src/publish_assistant/generate_content.py
Normal file
606
src/publish_assistant/generate_content.py
Normal 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 (Q1–Q4)\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()
|
||||
Reference in New Issue
Block a user