Coverage for src/local_deep_research/journal_quality/data_sources/predatory.py: 89%
57 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Stop Predatory Journals data source.
3Community successor to Jeffrey Beall's original predatory publishers
4list (Beall took down his original blog post in 2017). The successor
5project maintains three CSV files (publishers, journals, hijacked) on
6GitHub which we merge into a single predatory.json.
7"""
9from __future__ import annotations
11import csv
12import io
13import json
14from pathlib import Path
16from loguru import logger
18from .base import DataSource, atomic_replace
20_PREDATORY_BASE = (
21 "https://raw.githubusercontent.com/stop-predatory-journals/"
22 "stop-predatory-journals.github.io/master/_data"
23)
24_PREDATORY_FILES = {
25 "publishers": "publishers.csv",
26 "journals": "journals.csv",
27 "hijacked": "hijacked.csv",
28}
30# Safety floor — the upstream lists carry thousands of entries each.
31# Refuse to overwrite a healthy on-disk snapshot with a near-empty
32# payload (e.g. CDN partial outage where two of the three CSVs
33# returned 0 rows) since that would silently disable predatory
34# filtering for everyone.
35_MIN_PREDATORY_TOTAL = 100
38class PredatorySource(DataSource):
39 key = "predatory" # gitleaks:allow
40 name = "Stop Predatory Journals"
41 url = (
42 "https://github.com/stop-predatory-journals/"
43 "stop-predatory-journals.github.io"
44 )
45 dataset_url = (
46 "https://github.com/stop-predatory-journals/"
47 "stop-predatory-journals.github.io/tree/master/_data"
48 )
49 license = "MIT"
50 license_url = "https://opensource.org/license/mit"
51 description = (
52 "Community successor to Beall's List — predatory publishers, "
53 "journals, and hijacked journal entries"
54 )
55 filename = "predatory.json"
56 count_label = "predatory entries"
57 auto_download = True # ~0.3 MB; fetch on first filter use
58 required = False
59 approx_size_mb = 0.3
61 def fetch(self, data_dir: Path, progress_cb=None) -> int:
62 from ...security.safe_requests import (
63 safe_get_with_retries as safe_get,
64 )
66 publishers: list[dict] = []
67 journals: list[dict] = []
68 hijacked: list[dict] = []
70 def _read_csv(filename: str) -> list[dict]:
71 url = f"{_PREDATORY_BASE}/{filename}"
72 resp = safe_get(
73 url, timeout=30, consume_body=True, require_https=True
74 )
75 resp.raise_for_status()
76 reader = csv.DictReader(io.StringIO(resp.text))
77 return [
78 {k: (v or "").strip() for k, v in row.items() if k}
79 for row in reader
80 ]
82 for row in _read_csv(_PREDATORY_FILES["publishers"]):
83 name = row.get("name", "")
84 if name: 84 ↛ 82line 84 didn't jump to line 82 because the condition on line 84 was always true
85 publishers.append({"name": name, "url": row.get("url", "")})
87 for row in _read_csv(_PREDATORY_FILES["journals"]):
88 name = row.get("name", "")
89 if name: 89 ↛ 87line 89 didn't jump to line 87 because the condition on line 89 was always true
90 journals.append({"name": name, "url": row.get("url", "")})
92 for row in _read_csv(_PREDATORY_FILES["hijacked"]): 92 ↛ 96line 92 didn't jump to line 96 because the loop on line 92 never started
93 # Upstream column names: hijacked, hijackedabbr, hijackedurl,
94 # althijackedurl, authentic, authenticabbr, authenticurl.
95 # The rest of the codebase reads `hijacked_name`, so map across.
96 name = row.get("hijacked", "")
97 if name:
98 hijacked.append(
99 {
100 "hijacked_name": name,
101 "original_name": row.get("authentic", ""),
102 "hijacked_url": row.get("hijackedurl", ""),
103 "original_url": row.get("authenticurl", ""),
104 }
105 )
107 payload = {
108 "metadata": {
109 "source": (
110 "Stop Predatory Journals "
111 "(https://github.com/stop-predatory-journals/"
112 "stop-predatory-journals.github.io) — "
113 "community successor to Beall's List"
114 ),
115 "license": "MIT",
116 "publisher_count": len(publishers),
117 "journal_count": len(journals),
118 "hijacked_count": len(hijacked),
119 },
120 "publishers": publishers,
121 "journals": journals,
122 "hijacked": hijacked,
123 }
125 total = len(publishers) + len(journals) + len(hijacked)
126 if total < _MIN_PREDATORY_TOTAL:
127 raise RuntimeError(
128 f"Predatory: suspiciously few records "
129 f"({total} < {_MIN_PREDATORY_TOTAL}); refusing to "
130 "overwrite existing data"
131 )
133 output = data_dir / self.filename
134 tmp = data_dir / f"{self.filename}.tmp"
135 with open(tmp, "w", encoding="utf-8") as f:
136 json.dump(payload, f)
137 atomic_replace(tmp, output)
139 logger.info(
140 f"Predatory: saved {len(publishers)} publishers + "
141 f"{len(journals)} journals + {len(hijacked)} hijacked"
142 )
143 return total