#!/usr/bin/env python3
"""
wsu_predatory_scan.py
=====================
Scans Wright State University faculty publications (2020-present) and
cross-references each journal against known predatory journal lists.
Sources
-------
OpenAlex API (https://api.openalex.org) — free, no key required
Stop Predatory Journals dataset — community Beall's-successor
Beall's archived publisher list — fallback
Output
------
wsu_predatory_results.csv — every publication with predatory flag + reason
wsu_predatory_report.html — visual report with summary stats, top offenders
Usage
-----
pip install requests
python wsu_predatory_scan.py
Optional flags:
--from-year 2021 override start year (default 2020)
--out-dir /path/to/dir output directory (default: current dir)
--no-cache re-download predatory lists even if cached
"""
import argparse
import csv
import os
import sys
import time
import requests
from collections import Counter
from datetime import datetime
from html import escape
# ── Config ────────────────────────────────────────────────────────────────────
OPENALEX_BASE = "https://api.openalex.org"
EMAIL = "research@emerging-tech-lab.com" # OpenAlex polite pool
CACHE_DIR = ".wsu_scan_cache"
# Stop Predatory Journals (community-maintained Beall's successor)
SPJ_JOURNALS_URL = (
"https://raw.githubusercontent.com/stop-predatory-journals/"
"stop-predatory-journals.github.io/master/_data/journals.csv"
)
SPJ_PUBLISHERS_URL = (
"https://raw.githubusercontent.com/stop-predatory-journals/"
"stop-predatory-journals.github.io/master/_data/publishers.csv"
)
HEADERS = {"User-Agent": f"WSU-PredatoryScan/1.0 (mailto:{EMAIL})"}
# ── Argument parsing ──────────────────────────────────────────────────────────
def parse_args():
p = argparse.ArgumentParser(description="WSU predatory journal scan")
p.add_argument("--from-year", type=int, default=2020)
p.add_argument("--out-dir", type=str, default=".")
p.add_argument("--no-cache", action="store_true")
return p.parse_args()
# ── Cache helpers ─────────────────────────────────────────────────────────────
def _cache_path(filename):
os.makedirs(CACHE_DIR, exist_ok=True)
return os.path.join(CACHE_DIR, filename)
def _read_cache(filename):
p = _cache_path(filename)
if os.path.exists(p):
with open(p, encoding="utf-8") as f:
return f.read()
return None
def _write_cache(filename, content):
with open(_cache_path(filename), "w", encoding="utf-8") as f:
f.write(content)
# ── OpenAlex ──────────────────────────────────────────────────────────────────
def get_wsu_institution_id():
"""Resolve Wright State University to its OpenAlex institution ID."""
url = f"{OPENALEX_BASE}/institutions"
params = {"search": "Wright State University", "per-page": 5, "mailto": EMAIL}
r = requests.get(url, params=params, headers=HEADERS, timeout=15)
r.raise_for_status()
for inst in r.json().get("results", []):
if "wright state" in inst.get("display_name", "").lower():
# OpenAlex IDs are returned as full URLs; extract the short form
return inst["id"].split("/")[-1]
raise RuntimeError("Wright State University not found in OpenAlex. Check spelling.")
def fetch_all_works(institution_id, from_year):
"""Paginate through every WSU work from from_year onward."""
works = []
cursor = "*"
page = 0
filter_str = (
f"institutions.id:{institution_id},"
f"from_publication_date:{from_year}-01-01"
)
while True:
page += 1
params = {
"filter": filter_str,
"per-page": 200,
"cursor": cursor,
"select": "id,title,publication_year,primary_location,authorships,type",
"mailto": EMAIL,
}
sys.stdout.write(f"\r Page {page} — {len(works):,} works so far…")
sys.stdout.flush()
r = requests.get(
f"{OPENALEX_BASE}/works",
params=params,
headers=HEADERS,
timeout=30,
)
r.raise_for_status()
data = r.json()
batch = data.get("results", [])
if not batch:
break
works.extend(batch)
cursor = data.get("meta", {}).get("next_cursor")
if not cursor:
break
time.sleep(0.12) # polite pool: ~8 req/s ceiling
print(f"\r Fetched {len(works):,} works total. ")
return works
# ── Predatory lists ───────────────────────────────────────────────────────────
def load_predatory_lists(no_cache=False):
"""Download (or load from cache) the SPJ journal + publisher lists."""
def _get(url, cache_name):
if not no_cache:
cached = _read_cache(cache_name)
if cached:
return cached
print(f" Downloading {url} …")
try:
r = requests.get(url, headers=HEADERS, timeout=20)
r.raise_for_status()
text = r.text
_write_cache(cache_name, text)
return text
except Exception as exc:
print(f" WARNING: could not download {cache_name}: {exc}")
return ""
journals_csv = _get(SPJ_JOURNALS_URL, "spj_journals.csv")
publishers_csv = _get(SPJ_PUBLISHERS_URL, "spj_publishers.csv")
bad_issns = {} # issn (no dash) -> journal name
bad_j_names = set()
bad_publishers = set()
if journals_csv:
for row in csv.DictReader(journals_csv.splitlines()):
# Column names vary; try common variants
name = (
row.get("Journal") or row.get("journal") or
row.get("Name") or row.get("name") or ""
).strip()
issn = (
row.get("ISSN") or row.get("issn") or
row.get("Print ISSN") or row.get("Online ISSN") or ""
).strip().replace("-", "")
if issn:
bad_issns[issn] = name
if name:
bad_j_names.add(name.lower())
if publishers_csv:
for row in csv.DictReader(publishers_csv.splitlines()):
pub = (
row.get("Publisher") or row.get("publisher") or
row.get("Name") or row.get("name") or ""
).strip().lower()
if pub:
bad_publishers.add(pub)
return bad_issns, bad_j_names, bad_publishers
# ── Department / College extraction ──────────────────────────────────────────
import re
# WSU department keyword → college. Keys are lowercase substrings; first match wins.
_DEPT_TO_COLLEGE = {
# Boonshoft School of Medicine
"anatomy": "Boonshoft School of Medicine",
"biochemistry": "Boonshoft School of Medicine",
"community health": "Boonshoft School of Medicine",
"emergency medicine": "Boonshoft School of Medicine",
"geriatric": "Boonshoft School of Medicine",
"internal medicine": "Boonshoft School of Medicine",
"neuroscience": "Boonshoft School of Medicine",
"obstetrics": "Boonshoft School of Medicine",
"oncology": "Boonshoft School of Medicine",
"orthopedic": "Boonshoft School of Medicine",
"pathology": "Boonshoft School of Medicine",
"pediatric": "Boonshoft School of Medicine",
"pharmacology": "Boonshoft School of Medicine",
"physiology": "Boonshoft School of Medicine",
"psychiatry": "Boonshoft School of Medicine",
"radiology": "Boonshoft School of Medicine",
"surgery": "Boonshoft School of Medicine",
"urology": "Boonshoft School of Medicine",
"boonshoft": "Boonshoft School of Medicine",
"school of medicine": "Boonshoft School of Medicine",
# College of Science and Mathematics
"biological sciences": "College of Science and Mathematics",
"chemistry": "College of Science and Mathematics",
"earth and environmental":"College of Science and Mathematics",
"geological": "College of Science and Mathematics",
"math": "College of Science and Mathematics",
"physics": "College of Science and Mathematics",
"statistics": "College of Science and Mathematics",
# College of Engineering and Computer Science
"biomedical engineering":"College of Engineering and Computer Science",
"chemical engineering": "College of Engineering and Computer Science",
"computer science": "College of Engineering and Computer Science",
"computer engineering": "College of Engineering and Computer Science",
"electrical engineering":"College of Engineering and Computer Science",
"mechanical": "College of Engineering and Computer Science",
"industrial": "College of Engineering and Computer Science",
"systems engineering": "College of Engineering and Computer Science",
"human factors": "College of Engineering and Computer Science",
# College of Liberal Arts
"art history": "College of Liberal Arts",
"classics": "College of Liberal Arts",
"communication": "College of Liberal Arts",
"english": "College of Liberal Arts",
"film": "College of Liberal Arts",
"history": "College of Liberal Arts",
"international studies": "College of Liberal Arts",
"liberal arts": "College of Liberal Arts",
"modern languages": "College of Liberal Arts",
"music": "College of Liberal Arts",
"philosophy": "College of Liberal Arts",
"political science": "College of Liberal Arts",
"psychology": "College of Liberal Arts",
"religion": "College of Liberal Arts",
"social work": "College of Liberal Arts",
"sociology": "College of Liberal Arts",
"theatre": "College of Liberal Arts",
# Raj Soin College of Business
"accountancy": "Raj Soin College of Business",
"accounting": "Raj Soin College of Business",
"economics": "Raj Soin College of Business",
"finance": "Raj Soin College of Business",
"information systems": "Raj Soin College of Business",
"management": "Raj Soin College of Business",
"marketing": "Raj Soin College of Business",
"supply chain": "Raj Soin College of Business",
"raj soin": "Raj Soin College of Business",
# College of Nursing and Health
"nursing": "College of Nursing and Health",
"population health": "College of Nursing and Health",
"public health": "College of Nursing and Health",
"rehabilitation": "College of Nursing and Health",
# College of Education and Human Services
"counseling": "College of Education and Human Services",
"curriculum": "College of Education and Human Services",
"educational": "College of Education and Human Services",
"human services": "College of Education and Human Services",
"kinesiology": "College of Education and Human Services",
"leadership studies": "College of Education and Human Services",
"teacher": "College of Education and Human Services",
# Lake Campus
"lake campus": "Wright State University Lake Campus",
}
_DEPT_PATTERNS = [
re.compile(r'\bDept(?:artment)?\.?\s+of\s+([^,;]+)', re.IGNORECASE),
re.compile(r'\bDivision\s+of\s+([^,;]+)', re.IGNORECASE),
re.compile(r'\bSchool\s+of\s+([^,;]+)', re.IGNORECASE),
re.compile(r'\bProgram(?:s)?\s+in\s+([^,;]+)', re.IGNORECASE),
re.compile(r'\bInstitute\s+(?:of|for)\s+([^,;]+)', re.IGNORECASE),
re.compile(r'\bCenter\s+(?:for|of)\s+([^,;]+)', re.IGNORECASE),
re.compile(r'([A-Z][^,;]{3,40}?)\s+Department\b', re.IGNORECASE),
]
_SKIP = re.compile(r'^(OH|USA|US|Dayton|Ohio|United States)$', re.IGNORECASE)
def extract_dept_college(raw_affiliation_strings):
"""Parse department and college from raw OpenAlex affiliation strings.
Only considers strings that mention Wright State."""
dept = ""
college = ""
for aff in (raw_affiliation_strings or []):
if "wright state" not in aff.lower():
continue
for pat in _DEPT_PATTERNS:
m = pat.search(aff)
if m:
candidate = m.group(1).strip().rstrip(".,;")
if len(candidate) > 4 and not _SKIP.match(candidate):
if not dept:
dept = candidate
break
if not college and dept:
dept_lo = dept.lower()
for keyword, col in _DEPT_TO_COLLEGE.items():
if keyword in dept_lo:
college = col
break
if dept and college:
break
return dept, college
def check_predatory(venue, bad_issns, bad_j_names, bad_publishers):
"""Return (is_predatory: bool, reason: str)."""
if not venue:
return False, ""
source = venue.get("source") or {}
j_name = (source.get("display_name") or "").strip()
j_name_lo = j_name.lower()
publisher = (source.get("host_organization_name") or "").strip().lower()
issns = [i.replace("-", "") for i in (source.get("issn") or [])]
# 1. ISSN match — most reliable
for issn in issns:
if issn and issn in bad_issns:
return True, f"ISSN {issn}"
# 2. Exact journal name match
if j_name_lo and j_name_lo in bad_j_names:
return True, "journal name"
# 3. Publisher substring match
if publisher:
for bad_pub in bad_publishers:
if bad_pub and (bad_pub in publisher or publisher in bad_pub):
return True, f"publisher: {publisher}"
return False, ""
# ── Parse works ───────────────────────────────────────────────────────────────
def parse_works(works, bad_issns, bad_j_names, bad_publishers):
rows = []
for w in works:
title = (w.get("title") or "(untitled)").strip()
year = w.get("publication_year") or ""
wtype = w.get("type") or ""
venue = w.get("primary_location") or {}
source = (venue.get("source") or {})
journal = (source.get("display_name") or "").strip()
publisher = (source.get("host_organization_name") or "").strip()
issns = ", ".join(source.get("issn") or [])
oa_url = venue.get("landing_page_url") or ""
doi_raw = (w.get("id") or "") # OpenAlex ID, not DOI
flagged, reason = check_predatory(venue, bad_issns, bad_j_names, bad_publishers)
# Collect authors affiliated with Wright State; gather raw affiliation strings
wsu_authors = []
all_raw_affs = []
for auth in (w.get("authorships") or []):
inst_names = [
(i.get("display_name") or "").lower()
for i in (auth.get("institutions") or [])
]
raw_affs = auth.get("raw_affiliation_strings") or []
all_raw_affs.extend(raw_affs)
if any("wright state" in n for n in inst_names):
aname = (auth.get("author") or {}).get("display_name") or "Unknown"
wsu_authors.append(aname)
dept, college = extract_dept_college(all_raw_affs)
rows.append({
"year": year,
"authors": "; ".join(wsu_authors) or "(no WSU author listed)",
"department": dept,
"college": college,
"title": title,
"type": wtype,
"journal": journal,
"publisher": publisher,
"issns": issns,
"url": oa_url,
"predatory": "YES" if flagged else "no",
"reason": reason,
})
return rows
# ── CSV output ────────────────────────────────────────────────────────────────
def write_csv(rows, out_path):
if not rows:
print(" No rows to write.")
return
with open(out_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
print(f" CSV → {out_path}")
# ── HTML report ───────────────────────────────────────────────────────────────
def write_html(rows, out_path, from_year, generated_at):
flagged = [r for r in rows if r["predatory"] == "YES"]
total = len(rows)
n_flag = len(flagged)
pct = round(n_flag / total * 100, 1) if total else 0
top_journals = Counter(r["journal"] for r in flagged if r["journal"]).most_common(15)
top_authors = Counter()
for r in flagged:
for a in r["authors"].split("; "):
if a.strip() and a.strip() != "(no WSU author listed)":
top_authors[a.strip()] += 1
top_authors = top_authors.most_common(20)
def tr(r):
cls = ' class="flag"' if r["predatory"] == "YES" else ""
url = r.get("url", "")
link = (f'↗' if url else "")
auth = escape(r["authors"][:90]) + ("…" if len(r["authors"]) > 90 else "")
ttl = escape(r["title"][:110]) + ("…" if len(r["title"]) > 110 else "")
return (
f'
'
f'| {escape(str(r["year"]))} | '
f'{auth} | '
f'{escape(r.get("department",""))} | '
f'{escape(r.get("college",""))} | '
f'{ttl} | '
f'{escape(r["journal"])} | '
f'{escape(r["publisher"])} | '
f'{escape(r["predatory"])} | '
f'{escape(r["reason"])} | '
f'{link} | '
f'
'
)
flagged_rows = "".join(tr(r) for r in sorted(flagged, key=lambda r: str(r["year"]), reverse=True))
all_rows = "".join(tr(r) for r in sorted(rows, key=lambda r: str(r["year"]), reverse=True))
top_j_rows = "".join(f'| {escape(j)} | {n} |
' for j, n in top_journals)
top_a_rows = "".join(f'| {escape(a)} | {n} |
' for a, n in top_authors)
html = f"""
WSU Predatory Journal Scan — {generated_at[:10]}
§ ETL Research Integrity Report
Wright State University — Predatory Journal Scan
Publications {from_year}–2025 · Generated {generated_at[:10]} · Sources: OpenAlex API · Stop Predatory Journals
{total:,}
Total Publications
{n_flag:,}
Flagged Predatory
{len(top_journals)}
Distinct Predatory Journals
Top Predatory Journals
| Journal | # |
{top_j_rows or '| None flagged |
'}
Top Flagged Authors (WSU-affiliated)
| Author | # Flagged Pubs |
{top_a_rows or '| None flagged |
'}
Flagged Publications ({n_flag:,})
Rows highlighted red. Match types: ISSN · journal name · publisher substring.
| Year | WSU Authors | Department | College | Title | Journal | Publisher | Flag | Reason | Link |
{flagged_rows or '| No flagged publications found. |
'}
All Publications ({total:,})
Flagged rows highlighted. Download the CSV for ISSNs and full author lists.
| Year | WSU Authors | Department | College | Title | Journal | Publisher | Flag | Reason | Link |
{all_rows}
DISCLAIMER — This report is generated programmatically using the Stop Predatory Journals
community dataset and OpenAlex metadata. A "YES" flag means the journal matched a list
compiled by community editors — not a formal institutional determination. Verify flagged
entries manually before using for personnel or accreditation decisions. False positives
occur. Absence of a flag does not certify a journal as legitimate.
"""
with open(out_path, "w", encoding="utf-8") as f:
f.write(html)
print(f" HTML → {out_path}")
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
args = parse_args()
from_year = args.from_year
out_dir = args.out_dir
no_cache = args.no_cache
generated_at = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
out_csv = os.path.join(out_dir, "wsu_predatory_results.csv")
out_html = os.path.join(out_dir, "wsu_predatory_report.html")
print("=" * 60)
print(" WSU Publication Integrity Scan")
print(f" Publications {from_year} – 2025")
print("=" * 60)
print("\n[1/4] Resolving Wright State University in OpenAlex…")
try:
wsu_id = get_wsu_institution_id()
except Exception as exc:
print(f" ERROR: {exc}")
sys.exit(1)
print(f" Institution ID: {wsu_id}")
print("\n[2/4] Loading predatory journal lists…")
bad_issns, bad_j_names, bad_publishers = load_predatory_lists(no_cache)
print(
f" {len(bad_issns):,} ISSNs · "
f"{len(bad_j_names):,} journal names · "
f"{len(bad_publishers):,} publishers"
)
if not bad_issns and not bad_j_names:
print(" WARNING: predatory lists empty — check network and try --no-cache")
print("\n[3/4] Fetching WSU publications from OpenAlex…")
try:
works = fetch_all_works(wsu_id, from_year)
except Exception as exc:
print(f"\n ERROR fetching works: {exc}")
sys.exit(1)
if not works:
print(" No works returned. Check institution ID and date range.")
sys.exit(0)
print("\n[4/4] Cross-referencing and writing output…")
rows = parse_works(works, bad_issns, bad_j_names, bad_publishers)
n_flag = sum(1 for r in rows if r["predatory"] == "YES")
write_csv(rows, out_csv)
write_html(rows, out_html, from_year, generated_at)
print()
print("=" * 60)
print(f" COMPLETE")
print(f" {len(rows):,} publications scanned")
print(f" {n_flag:,} flagged ({round(n_flag/len(rows)*100,1) if rows else 0}%)")
print(f" Output: {out_csv}")
print(f" Output: {out_html}")
print("=" * 60)
print()
if __name__ == "__main__":
main()