risforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
risforge/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """risforge: clean, deduplicate, and enrich RIS bibliographic files.
2
+
3
+ Typical usage::
4
+
5
+ from risforge import clean_ris_file, RisEnricher, run_pipeline
6
+
7
+ # Clean + deduplicate only
8
+ records, errors = clean_ris_file("raw.ris", "clean.ris")
9
+
10
+ # Enrich only
11
+ enricher = RisEnricher(email="you@example.com")
12
+ stats = enricher.enrich_file("clean.ris", "enriched.ris")
13
+
14
+ # Both, in one call
15
+ result = run_pipeline(
16
+ "raw.ris", "clean.ris", "enriched.ris", email="you@example.com"
17
+ )
18
+
19
+ See the ``risforge`` console script (``risforge --help``) for the
20
+ equivalent command-line interface.
21
+ """
22
+
23
+ from risforge.cleaning import clean_ris_file, process_ris_file
24
+ from risforge.enrichment import RisEnricher
25
+ from risforge.exceptions import RisForgeError, RisParsingError
26
+ from risforge.pipeline import PipelineResult, run_pipeline
27
+
28
+ __version__ = "0.1.0"
29
+
30
+ __all__ = [
31
+ "clean_ris_file",
32
+ "process_ris_file",
33
+ "RisEnricher",
34
+ "run_pipeline",
35
+ "PipelineResult",
36
+ "RisForgeError",
37
+ "RisParsingError",
38
+ ]
risforge/cleaning.py ADDED
@@ -0,0 +1,318 @@
1
+ """Clean, normalize, and deduplicate RIS bibliographic records.
2
+
3
+ This module contains no behavioral changes from the original
4
+ ``clean_ris.py`` script: the union-find-based clustering, the DOI and
5
+ title+author matching heuristics, and the "most complete record wins,
6
+ then merge in whatever the others have that it's missing" merge
7
+ strategy are all preserved exactly. What changed is purely structural:
8
+ type hints, docstrings, PEP 8 formatting, and turning the script's
9
+ module-level ``logging.basicConfig()`` call into a plain
10
+ ``logging.getLogger(__name__)`` (a library should never configure the
11
+ root logger on import -- see :mod:`risforge.cli` for where that
12
+ configuration now happens, only for CLI usage).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import re
19
+ import unicodedata
20
+ from collections import defaultdict
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import rispy
25
+ import rispy.writer
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ RisRecord = dict[str, Any]
30
+
31
+
32
+ class _CleanRisWriter(rispy.writer.RisWriter):
33
+ """Writer that strips rispy's default "1.", "2." record numbering.
34
+
35
+ Enforces ``\\r\\n`` line endings for compatibility with reference
36
+ managers (EndNote, Zotero, etc.) that expect the RIS spec's
37
+ canonical newline convention.
38
+ """
39
+
40
+ NEWLINE = "\r\n"
41
+
42
+ def set_header(self, count: int) -> str:
43
+ return ""
44
+
45
+
46
+ def normalize_title(title: str | None) -> str:
47
+ """Normalize a title for deduplication comparison.
48
+
49
+ Lowercases, strips diacritics, and removes punctuation so that
50
+ casing and formatting differences across citation exports don't
51
+ produce false-negative duplicate matches.
52
+ """
53
+ if not isinstance(title, str) or not title:
54
+ return ""
55
+
56
+ normalized = unicodedata.normalize("NFKD", title).lower()
57
+ normalized = re.sub(r"[^a-z0-9\s]", "", normalized)
58
+ return re.sub(r"\s+", " ", normalized).strip()
59
+
60
+
61
+ def extract_first_author(author_list: list[str] | None) -> str:
62
+ """Extract a normalized "last name + initials" key for the first author.
63
+
64
+ Used as half of the title+author composite deduplication key, since
65
+ full author-list formatting is rarely consistent across sources but
66
+ the first author's surname usually is.
67
+ """
68
+ if not author_list or not isinstance(author_list[0], str):
69
+ return ""
70
+
71
+ author_parts = author_list[0].split(",")
72
+ last_name = author_parts[0].strip()
73
+ initials = ""
74
+
75
+ if len(author_parts) > 1:
76
+ tokens = re.split(r"[\s.\-]+", author_parts[1].strip())
77
+ initials = "".join(token[0] for token in tokens if token)
78
+
79
+ name_str = f"{last_name} {initials}"
80
+ name_str = unicodedata.normalize("NFKD", name_str).lower()
81
+ name_str = re.sub(r"[^a-z0-9\s]", "", name_str)
82
+ return re.sub(r"\s+", " ", name_str).strip()
83
+
84
+
85
+ def normalize_doi(doi: str | None) -> str:
86
+ """Normalize a DOI by stripping URL prefixes, whitespace, and trailing punctuation.
87
+
88
+ DOIs are the strongest deduplication key available, but sources
89
+ frequently prepend ``https://doi.org/`` or ``doi:`` in ways that
90
+ would otherwise defeat exact matching.
91
+ """
92
+ if not isinstance(doi, str) or not doi:
93
+ return ""
94
+
95
+ cleaned = doi.strip().lower()
96
+ cleaned = re.sub(r"^(https?://)?(dx\.)?doi\.org/|^doi:", "", cleaned)
97
+ return re.sub(r"[\s.,;:]+$", "", cleaned)
98
+
99
+
100
+ def count_fields(record: RisRecord) -> int:
101
+ """Count populated fields in a record.
102
+
103
+ Used to pick the "most complete" record in a duplicate cluster as
104
+ the merge base.
105
+ """
106
+ count = 0
107
+ for key, value in record.items():
108
+ if key == "unknown_tag":
109
+ for unknown_values in value.values():
110
+ count += sum(bool(item) for item in unknown_values)
111
+ elif isinstance(value, list):
112
+ count += sum(bool(item) for item in value)
113
+ elif value:
114
+ count += 1
115
+ return count
116
+
117
+
118
+ def merge_cluster(cluster: list[RisRecord]) -> RisRecord:
119
+ """Merge a cluster of duplicate records into one, losing no data.
120
+
121
+ The most complete record (by :func:`count_fields`) is used as the
122
+ base; every other record in the cluster then supplements it with
123
+ any fields or list items it's missing.
124
+ """
125
+ if len(cluster) == 1:
126
+ return cluster[0]
127
+
128
+ best_record = max(cluster, key=count_fields)
129
+ merged = dict(best_record)
130
+ merged["unknown_tag"] = defaultdict(list, merged.get("unknown_tag", {}))
131
+
132
+ for record in cluster:
133
+ if record is best_record:
134
+ continue
135
+
136
+ for key, value in record.items():
137
+ if key == "unknown_tag":
138
+ for unknown_key, unknown_values in value.items():
139
+ for item in unknown_values:
140
+ if item not in merged["unknown_tag"][unknown_key]:
141
+ merged["unknown_tag"][unknown_key].append(item)
142
+ elif key not in merged:
143
+ merged[key] = value
144
+ elif isinstance(merged[key], list) and isinstance(value, list):
145
+ for item in value:
146
+ if item not in merged[key]:
147
+ merged[key].append(item)
148
+ elif (
149
+ isinstance(merged[key], str)
150
+ and not merged[key]
151
+ and isinstance(value, str)
152
+ ):
153
+ merged[key] = value
154
+
155
+ return merged
156
+
157
+
158
+ class _RecordUnionFind:
159
+ """Disjoint-set structure grouping records into duplicate clusters.
160
+
161
+ Two records may only be unioned if neither has a normalized DOI
162
+ that conflicts with the other's -- this prevents the fuzzy
163
+ title+author heuristic from ever merging two records that carry
164
+ different, explicit DOIs.
165
+ """
166
+
167
+ def __init__(self, records: list[RisRecord]) -> None:
168
+ self._parents = list(range(len(records)))
169
+ self._root_dois = [normalize_doi(record.get("doi", "")) for record in records]
170
+
171
+ def find(self, node_index: int) -> int:
172
+ root = node_index
173
+ while self._parents[root] != root:
174
+ root = self._parents[root]
175
+
176
+ current = node_index
177
+ while current != root:
178
+ nxt = self._parents[current]
179
+ self._parents[current] = root
180
+ current = nxt
181
+
182
+ return root
183
+
184
+ def union(self, node_i: int, node_j: int) -> bool:
185
+ root_i = self.find(node_i)
186
+ root_j = self.find(node_j)
187
+
188
+ if root_i == root_j:
189
+ return False
190
+
191
+ doi_i = self._root_dois[root_i]
192
+ doi_j = self._root_dois[root_j]
193
+
194
+ if doi_i and doi_j and doi_i != doi_j:
195
+ return False
196
+
197
+ self._parents[root_i] = root_j
198
+ self._root_dois[root_j] = doi_i or doi_j
199
+ return True
200
+
201
+
202
+ def clean_ris_file(
203
+ input_path: str | Path, output_path: str | Path
204
+ ) -> tuple[list[RisRecord], list[tuple[int, str]]]:
205
+ """Clean, deduplicate, and write out a RIS file.
206
+
207
+ Parses ``input_path`` block-by-block (so a single malformed record
208
+ doesn't take down the whole parse), deduplicates first on exact
209
+ normalized DOI, then on a normalized title+first-author composite
210
+ key, merges each resulting cluster into a single complete record,
211
+ and writes the result to ``output_path``.
212
+
213
+ Args:
214
+ input_path: Path to the source ``.ris`` file.
215
+ output_path: Path the cleaned, deduplicated ``.ris`` file is
216
+ written to.
217
+
218
+ Returns:
219
+ A ``(records, errors)`` tuple: the final deduplicated records
220
+ (as rispy record dicts), and a list of ``(block_number,
221
+ message)`` pairs for any blocks that failed to parse.
222
+
223
+ Raises:
224
+ FileNotFoundError: If ``input_path`` does not exist.
225
+ """
226
+ input_path = Path(input_path)
227
+ output_path = Path(output_path)
228
+
229
+ if not input_path.exists():
230
+ raise FileNotFoundError(f"Input file '{input_path}' not found.")
231
+
232
+ text = input_path.read_text(encoding="utf-8")
233
+
234
+ blocks = re.split(r"(?m)^TY\s+-", text)
235
+ records: list[RisRecord] = []
236
+ errors: list[tuple[int, str]] = []
237
+
238
+ for index, block in enumerate(blocks):
239
+ if not block.strip():
240
+ continue
241
+
242
+ block_text = f"TY -{block}"
243
+ try:
244
+ parsed_records = rispy.loads(block_text)
245
+ if not parsed_records:
246
+ errors.append((index + 1, "Empty parse result (malformed record)"))
247
+ else:
248
+ records.extend(parsed_records)
249
+ except (ValueError, TypeError, KeyError, AttributeError) as error:
250
+ errors.append((index + 1, str(error)))
251
+
252
+ if errors:
253
+ logger.warning("Encountered %d malformed record block(s), skipped.", len(errors))
254
+
255
+ total_records = len(records)
256
+ if total_records == 0:
257
+ logger.warning("No valid records found to process.")
258
+ return [], errors
259
+
260
+ union_find = _RecordUnionFind(records)
261
+
262
+ doi_map: dict[str, int] = {}
263
+ doi_duplicates_removed = 0
264
+
265
+ # Pass 1: deduplicate by exact DOI match.
266
+ for index, record in enumerate(records):
267
+ doi = normalize_doi(record.get("doi", ""))
268
+ if doi:
269
+ if doi in doi_map:
270
+ if union_find.union(index, doi_map[doi]):
271
+ doi_duplicates_removed += 1
272
+ else:
273
+ doi_map[doi] = index
274
+
275
+ composite_key_map: dict[str, int] = {}
276
+ title_author_duplicates_removed = 0
277
+
278
+ # Pass 2: deduplicate by composite title + first-author key (fallback heuristic).
279
+ for index, record in enumerate(records):
280
+ norm_title = normalize_title(record.get("title", ""))
281
+ norm_author = extract_first_author(record.get("authors", []))
282
+
283
+ if norm_title and norm_author:
284
+ composite_key = f"{norm_title}|{norm_author}"
285
+
286
+ if composite_key in composite_key_map:
287
+ if union_find.union(index, composite_key_map[composite_key]):
288
+ title_author_duplicates_removed += 1
289
+ composite_key_map[composite_key] = union_find.find(index)
290
+ else:
291
+ composite_key_map[composite_key] = union_find.find(index)
292
+
293
+ clusters: dict[int, list[int]] = defaultdict(list)
294
+ for index in range(total_records):
295
+ clusters[union_find.find(index)].append(index)
296
+
297
+ final_records = [
298
+ merge_cluster([records[i] for i in indices]) for indices in clusters.values()
299
+ ]
300
+
301
+ total_duplicates_removed = doi_duplicates_removed + title_author_duplicates_removed
302
+
303
+ logger.info("--- SUMMARY ---")
304
+ logger.info("Input records successfully parsed: %d", len(records))
305
+ logger.info("Malformed records skipped: %d", len(errors))
306
+ logger.info("Total duplicates removed: %d", total_duplicates_removed)
307
+ logger.info("Final unique records: %d", len(final_records))
308
+ logger.info("Output saved to: %s", output_path)
309
+ logger.info("---------------")
310
+
311
+ out_text = rispy.dumps(final_records, implementation=_CleanRisWriter)
312
+ output_path.write_text(out_text, encoding="utf-8")
313
+
314
+ return final_records, errors
315
+
316
+
317
+ # Backward-compatible alias for the original script's public function name.
318
+ process_ris_file = clean_ris_file
risforge/cli.py ADDED
@@ -0,0 +1,197 @@
1
+ """Command-line interface for risforge.
2
+
3
+ This is where the original scripts' "just run me directly" behavior
4
+ now lives, generalized into three subcommands so a single installed
5
+ ``risforge`` command replaces having three separate scripts each
6
+ hardcoding its own paths:
7
+
8
+ risforge clean input.ris output_clean.ris
9
+ risforge enrich input.ris output_enriched.ris --email you@example.com
10
+ risforge pipeline input.ris --email you@example.com
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import logging
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from risforge.cleaning import clean_ris_file
21
+ from risforge.enrichment import RisEnricher
22
+ from risforge.pipeline import run_pipeline
23
+
24
+ logger = logging.getLogger("risforge")
25
+
26
+
27
+ def _configure_logging(verbose: bool) -> None:
28
+ """Configure logging for CLI usage only.
29
+
30
+ Library modules never do this themselves (see the module
31
+ docstrings in :mod:`risforge.cleaning` and
32
+ :mod:`risforge.enrichment`) -- only the CLI entry point, which is
33
+ the one context where it's actually risforge's call to make.
34
+ """
35
+ logging.basicConfig(
36
+ level=logging.DEBUG if verbose else logging.INFO,
37
+ format="%(levelname)s: %(message)s",
38
+ )
39
+
40
+
41
+ def _add_common_args(parser: argparse.ArgumentParser) -> None:
42
+ parser.add_argument(
43
+ "-v", "--verbose", action="store_true", help="Enable debug-level logging."
44
+ )
45
+
46
+
47
+ def _cmd_clean(args: argparse.Namespace) -> int:
48
+ try:
49
+ records, errors = clean_ris_file(args.input, args.output)
50
+ except FileNotFoundError as error:
51
+ logger.error("%s", error)
52
+ return 1
53
+ except (OSError, ValueError, RuntimeError) as error:
54
+ logger.error("Cleaning failed: %s", error)
55
+ return 1
56
+
57
+ logger.info("Successfully processed %d unique records.", len(records))
58
+ return 1 if errors and args.strict else 0
59
+
60
+
61
+ def _cmd_enrich(args: argparse.Namespace) -> int:
62
+ try:
63
+ enricher = RisEnricher(email=args.email, cache_name=args.cache_name)
64
+ stats = enricher.enrich_file(
65
+ input_path=args.input,
66
+ output_path=args.output,
67
+ fail_report_path=args.fail_report,
68
+ )
69
+ except (OSError, ValueError, RuntimeError) as error:
70
+ logger.error("Enrichment failed: %s", error)
71
+ return 1
72
+
73
+ logger.info("Enriched %d/%d records.", stats["enriched"], stats["processed"])
74
+ return 0
75
+
76
+
77
+ def _cmd_pipeline(args: argparse.Namespace) -> int:
78
+ dedup_path = args.dedup_output or _default_sibling(args.input, "_clean")
79
+ enriched_path = args.output or _default_sibling(args.input, "_enriched")
80
+
81
+ try:
82
+ result = run_pipeline(
83
+ input_path=args.input,
84
+ dedup_path=dedup_path,
85
+ enriched_path=enriched_path,
86
+ email=args.email,
87
+ fail_report_path=args.fail_report,
88
+ )
89
+ except FileNotFoundError as error:
90
+ logger.error("Input file missing: %s", error)
91
+ return 1
92
+ except (OSError, ValueError, RuntimeError) as error:
93
+ logger.error("Pipeline failed: %s", error)
94
+ return 1
95
+
96
+ logger.info(
97
+ "Pipeline finished: %d cleaned records, %d/%d enriched.",
98
+ result.cleaned_record_count,
99
+ result.enrichment_stats.get("enriched", 0),
100
+ result.enrichment_stats.get("processed", 0),
101
+ )
102
+ return 0
103
+
104
+
105
+ def _default_sibling(input_path: str | Path, suffix: str) -> Path:
106
+ path = Path(input_path)
107
+ return path.with_name(f"{path.stem}{suffix}{path.suffix}")
108
+
109
+
110
+ def build_parser() -> argparse.ArgumentParser:
111
+ """Build the top-level argument parser (exposed for testing/docs)."""
112
+ parser = argparse.ArgumentParser(
113
+ prog="risforge",
114
+ description="Clean, deduplicate, and enrich RIS bibliographic files.",
115
+ )
116
+ subparsers = parser.add_subparsers(dest="command", required=True)
117
+
118
+ clean_parser = subparsers.add_parser(
119
+ "clean", help="Deduplicate and normalize a RIS file."
120
+ )
121
+ clean_parser.add_argument("input", help="Input RIS file path.")
122
+ clean_parser.add_argument("output", help="Output cleaned RIS file path.")
123
+ clean_parser.add_argument(
124
+ "--strict",
125
+ action="store_true",
126
+ help="Exit with a non-zero status if any records failed to parse.",
127
+ )
128
+ _add_common_args(clean_parser)
129
+ clean_parser.set_defaults(func=_cmd_clean)
130
+
131
+ enrich_parser = subparsers.add_parser(
132
+ "enrich", help="Enrich a RIS file with metadata from scholarly APIs."
133
+ )
134
+ enrich_parser.add_argument("input", help="Input RIS file path.")
135
+ enrich_parser.add_argument("output", help="Output enriched RIS file path.")
136
+ enrich_parser.add_argument(
137
+ "--email",
138
+ required=True,
139
+ help="Contact email for Crossref/OpenAlex/Unpaywall polite-pool access.",
140
+ )
141
+ enrich_parser.add_argument(
142
+ "--fail-report",
143
+ dest="fail_report",
144
+ default="failed_records.json",
145
+ help="Where to write unresolved-DOI records (default: %(default)s).",
146
+ )
147
+ enrich_parser.add_argument(
148
+ "--cache-name",
149
+ dest="cache_name",
150
+ default=".api_cache",
151
+ help="Base filename for the on-disk HTTP response cache.",
152
+ )
153
+ _add_common_args(enrich_parser)
154
+ enrich_parser.set_defaults(func=_cmd_enrich)
155
+
156
+ pipeline_parser = subparsers.add_parser(
157
+ "pipeline", help="Run clean then enrich in one step."
158
+ )
159
+ pipeline_parser.add_argument("input", help="Input RIS file path.")
160
+ pipeline_parser.add_argument(
161
+ "--email",
162
+ required=True,
163
+ help="Contact email for Crossref/OpenAlex/Unpaywall polite-pool access.",
164
+ )
165
+ pipeline_parser.add_argument(
166
+ "--dedup-output",
167
+ dest="dedup_output",
168
+ default=None,
169
+ help="Path for the intermediate cleaned file (default: <input>_clean.ris).",
170
+ )
171
+ pipeline_parser.add_argument(
172
+ "--output",
173
+ default=None,
174
+ help="Path for the final enriched file (default: <input>_enriched.ris).",
175
+ )
176
+ pipeline_parser.add_argument(
177
+ "--fail-report",
178
+ dest="fail_report",
179
+ default="failed_records.json",
180
+ help="Where to write unresolved-DOI records (default: %(default)s).",
181
+ )
182
+ _add_common_args(pipeline_parser)
183
+ pipeline_parser.set_defaults(func=_cmd_pipeline)
184
+
185
+ return parser
186
+
187
+
188
+ def main(argv: list[str] | None = None) -> None:
189
+ """CLI entry point (registered as the ``risforge`` console script)."""
190
+ parser = build_parser()
191
+ args = parser.parse_args(argv)
192
+ _configure_logging(args.verbose)
193
+ sys.exit(args.func(args))
194
+
195
+
196
+ if __name__ == "__main__":
197
+ main()
risforge/enrichment.py ADDED
@@ -0,0 +1,420 @@
1
+ """Enrich RIS citation records by aggregating metadata from scholarly APIs.
2
+
3
+ Behavior is unchanged from the original ``enrich_ris.py``: the same
4
+ four providers (Crossref, OpenAlex, Semantic Scholar, Unpaywall) are
5
+ queried in the same order, existing fields are still never
6
+ overwritten (only gaps are filled), and the same retry/backoff and
7
+ 7-day response cache are used.
8
+
9
+ Two structural changes were made, both in service of the same goal --
10
+ letting this be imported as a library, not just run as a script:
11
+
12
+ 1. The module no longer attaches a ``StreamHandler`` to its logger at
13
+ import time. A library should never configure logging as a side
14
+ effect of being imported; the calling application (or
15
+ :mod:`risforge.cli`) decides where log records go.
16
+ 2. :class:`RisEnricher` now accepts an optional pre-built ``session``,
17
+ so tests (and callers with their own HTTP session/retry policy) can
18
+ inject one instead of always getting a fresh
19
+ ``requests_cache.CachedSession`` pointed at a file on disk.
20
+
21
+ One genuine bug from the original script *was* fixed here, not just
22
+ restructured: the original ``RIS_MAPPING`` (and ``extract_doi``)
23
+ addressed record fields by RIS tag mnemonic (``"DO"``, ``"TI"``,
24
+ ``"T2"``, ...). But ``rispy`` -- both the parser and the writer --
25
+ represents records by *logical* field name (``"doi"``, ``"title"``,
26
+ ``"secondary_title"``, ...), not by tag. Writing to ``record["DO"]``
27
+ therefore silently created a key rispy's writer doesn't recognize, and
28
+ ``rispy.dump()`` dropped it on write (emitting a ``UserWarning: label
29
+ `DO` not exported`` in the process). In practice, every field the
30
+ original enricher "added" never actually made it into the output
31
+ file, and ``extract_doi()`` could never find a DOI that was already
32
+ present on the record either, since it looked for ``"DO"`` instead of
33
+ ``"doi"``. :data:`RISPY_FIELD_MAP` below uses rispy's real field
34
+ names, and :meth:`RisEnricher.extract_doi` reads ``"doi"``/``"urls"``.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import logging
41
+ import re
42
+ import time
43
+ from difflib import SequenceMatcher
44
+ from pathlib import Path
45
+ from typing import Any
46
+ from urllib.parse import quote
47
+
48
+ import requests
49
+ import requests_cache
50
+ import rispy
51
+ from requests.adapters import HTTPAdapter
52
+ from urllib3.util.retry import Retry
53
+
54
+ TITLE_MATCH_THRESHOLD = 0.90
55
+ CACHE_EXPIRE_DAYS = 7
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ # Maps our enrichment payload's logical keys to rispy's actual record
60
+ # field names (NOT RIS tag mnemonics -- see the module docstring).
61
+ RISPY_FIELD_MAP: dict[str, str] = {
62
+ "doi": "doi",
63
+ "title": "title",
64
+ "authors": "authors",
65
+ "journal": "secondary_title", # rispy has no single canonical "journal" field;
66
+ "publisher": "publisher", # secondary_title (T2) is where journal names live
67
+ "year": "year", # in the vast majority of real-world RIS exports.
68
+ "date": "date",
69
+ "volume": "volume",
70
+ "issue": "number",
71
+ "start_page": "start_page",
72
+ "end_page": "end_page",
73
+ "abstract": "abstract",
74
+ "issn": "issn",
75
+ "url": "urls", # rispy stores URLs as a list (RIS "UR" is a list-type tag).
76
+ "pdf_url": "file_attachments1", # RIS "L1", used for PDF links in Zotero/EndNote.
77
+ "keywords": "keywords",
78
+ }
79
+
80
+ # rispy fields that are always lists, regardless of what the source API gives us.
81
+ _LIST_TYPE_FIELDS = {"authors", "keywords", "urls"}
82
+
83
+
84
+ def _build_default_session(cache_name: str) -> requests.Session:
85
+ """Build the package's default cached, retrying HTTP session."""
86
+ session = requests_cache.CachedSession(
87
+ cache_name,
88
+ expire_after=CACHE_EXPIRE_DAYS * 86400,
89
+ allowable_codes=[200, 404],
90
+ )
91
+ retries = Retry(
92
+ total=5,
93
+ backoff_factor=1,
94
+ status_forcelist=[429, 500, 502, 503, 504],
95
+ allowed_methods=["GET"],
96
+ )
97
+ session.mount("https://", HTTPAdapter(max_retries=retries))
98
+ return session
99
+
100
+
101
+ class RisEnricher:
102
+ """Enriches RIS citation records with metadata from scholarly APIs.
103
+
104
+ Args:
105
+ email: Contact email sent to Crossref/OpenAlex/Unpaywall as
106
+ required by their "polite pool" usage terms.
107
+ cache_name: Base filename for the on-disk HTTP response cache
108
+ (only used when ``session`` is not provided).
109
+ session: Optional pre-built ``requests.Session`` (or
110
+ ``requests_cache.CachedSession``). Mainly useful for
111
+ testing -- pass a mocked session to avoid real network
112
+ calls. When omitted, a cached, retrying session is built
113
+ automatically.
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ email: str,
119
+ cache_name: str | Path = ".api_cache",
120
+ session: requests.Session | None = None,
121
+ ) -> None:
122
+ self.email = email
123
+ self.stats: dict[str, Any] = {
124
+ "processed": 0,
125
+ "enriched": 0,
126
+ "failed": 0,
127
+ "api_calls": {
128
+ "crossref": 0,
129
+ "openalex": 0,
130
+ "semanticscholar": 0,
131
+ "unpaywall": 0,
132
+ },
133
+ }
134
+ self.failed_records: list[dict[str, Any]] = []
135
+ self.session = session or _build_default_session(str(cache_name))
136
+ self.session.headers.update(
137
+ {"User-Agent": f"risforge/1.0 (mailto:{self.email})"}
138
+ )
139
+
140
+ # --- Core identification -------------------------------------------------
141
+
142
+ def extract_doi(self, record: dict[str, Any]) -> str | None:
143
+ """Extract and validate a DOI from a rispy record's doi/urls fields."""
144
+ doi = record.get("doi", "")
145
+
146
+ if not doi:
147
+ urls = record.get("urls", "")
148
+ doi = " ".join(urls) if isinstance(urls, list) else urls
149
+
150
+ if not isinstance(doi, str):
151
+ doi = str(doi)
152
+
153
+ match = re.search(r"(10\.\d{4,9}/[-._;()/:A-Z0-9]+)", doi, re.IGNORECASE)
154
+ return match.group(1).lower() if match else None
155
+
156
+ def string_similarity(self, source_text: str, target_text: str) -> float:
157
+ """Fuzzy-match ratio between two strings (used for title matching)."""
158
+ if not source_text or not target_text:
159
+ return 0.0
160
+ return SequenceMatcher(None, source_text.lower(), target_text.lower()).ratio()
161
+
162
+ def resolve_doi_by_title(self, title: str) -> str | None:
163
+ """Fall back to a Crossref title search when a record has no DOI."""
164
+ if not title:
165
+ return None
166
+
167
+ url = (
168
+ f"https://api.crossref.org/works?query.title={quote(title)}"
169
+ f"&select=DOI,title&rows=3&mailto={self.email}"
170
+ )
171
+ try:
172
+ response = self.session.get(url, timeout=10)
173
+ self.stats["api_calls"]["crossref"] += 1
174
+
175
+ if response.status_code == 200:
176
+ items = response.json().get("message", {}).get("items", [])
177
+ for item in items:
178
+ api_titles = item.get("title", [""])
179
+ api_title = api_titles[0] if api_titles else ""
180
+
181
+ if self.string_similarity(title, api_title) >= TITLE_MATCH_THRESHOLD:
182
+ doi_value = item.get("DOI")
183
+ if doi_value:
184
+ return str(doi_value).lower()
185
+
186
+ except (requests.RequestException, ValueError, KeyError) as error:
187
+ logger.warning("Title resolution failed for '%s': %s", title, error)
188
+
189
+ return None
190
+
191
+ # --- API integrations ------------------------------------------------------
192
+
193
+ def fetch_crossref_metadata(self, doi: str) -> dict[str, Any]:
194
+ """Fetch authoritative bibliographic metadata from Crossref."""
195
+ url = f"https://api.crossref.org/works/{quote(doi)}?mailto={self.email}"
196
+ try:
197
+ response = self.session.get(url, timeout=10)
198
+ self.stats["api_calls"]["crossref"] += 1
199
+
200
+ if response.status_code == 200:
201
+ payload = response.json().get("message", {})
202
+
203
+ titles = payload.get("title", [""])
204
+ article_title = titles[0] if titles else ""
205
+
206
+ authors_list = []
207
+ for author_data in payload.get("author", []):
208
+ family = author_data.get("family", "")
209
+ given = author_data.get("given", "")
210
+ author_str = f"{family}, {given}".strip(", ")
211
+ if author_str:
212
+ authors_list.append(author_str)
213
+
214
+ containers = payload.get("container-title", [""])
215
+ journal_title = containers[0] if containers else ""
216
+
217
+ issued_parts = payload.get("issued", {}).get("date-parts", [[None]])
218
+ published_year = None
219
+ if issued_parts and issued_parts[0] and issued_parts[0][0] is not None:
220
+ published_year = str(issued_parts[0][0])
221
+
222
+ page_string = payload.get("page", "")
223
+ start_page, end_page = None, None
224
+ if page_string:
225
+ pages = page_string.split("-")
226
+ start_page = pages[0] if pages else None
227
+ end_page = pages[1] if len(pages) > 1 else None
228
+
229
+ issns = payload.get("ISSN", [""])
230
+ issn_value = issns[0] if issns else None
231
+
232
+ return {
233
+ "title": article_title,
234
+ "authors": authors_list,
235
+ "journal": journal_title,
236
+ "publisher": payload.get("publisher"),
237
+ "year": published_year,
238
+ "volume": payload.get("volume"),
239
+ "issue": payload.get("issue"),
240
+ "start_page": start_page,
241
+ "end_page": end_page,
242
+ "issn": issn_value,
243
+ }
244
+
245
+ except (requests.RequestException, ValueError, KeyError, IndexError) as error:
246
+ logger.warning("Crossref failed for %s: %s", doi, error)
247
+
248
+ return {}
249
+
250
+ def fetch_openalex_metadata(self, doi: str) -> dict[str, Any]:
251
+ """Fetch open-access status and subject concepts from OpenAlex."""
252
+ url = f"https://api.openalex.org/works/doi:{quote(doi)}?mailto={self.email}"
253
+ try:
254
+ response = self.session.get(url, timeout=10)
255
+ self.stats["api_calls"]["openalex"] += 1
256
+
257
+ if response.status_code == 200:
258
+ payload = response.json()
259
+ concepts = [
260
+ c.get("display_name")
261
+ for c in payload.get("concepts", [])
262
+ if c.get("level", 99) <= 1 and c.get("display_name")
263
+ ]
264
+ oa_url = payload.get("open_access", {}).get("oa_url")
265
+ return {
266
+ "keywords": concepts,
267
+ "pdf_url": oa_url,
268
+ "url": payload.get("id"),
269
+ }
270
+
271
+ except (requests.RequestException, ValueError, KeyError) as error:
272
+ logger.warning("OpenAlex failed for %s: %s", doi, error)
273
+
274
+ return {}
275
+
276
+ def fetch_semanticscholar_metadata(self, doi: str) -> dict[str, Any]:
277
+ """Fetch parsed abstract text from Semantic Scholar."""
278
+ url = (
279
+ f"https://api.semanticscholar.org/graph/v1/paper/DOI:{quote(doi)}"
280
+ f"?fields=abstract,referenceCount,citationCount"
281
+ )
282
+ try:
283
+ response = self.session.get(url, timeout=10)
284
+ self.stats["api_calls"]["semanticscholar"] += 1
285
+
286
+ if response.status_code == 200:
287
+ payload = response.json()
288
+ return {"abstract": payload.get("abstract")}
289
+
290
+ except (requests.RequestException, ValueError, KeyError) as error:
291
+ logger.warning("Semantic Scholar failed for %s: %s", doi, error)
292
+
293
+ return {}
294
+
295
+ def fetch_unpaywall_pdf(self, doi: str) -> dict[str, Any]:
296
+ """Fetch the best open-access PDF location from Unpaywall."""
297
+ url = f"https://api.unpaywall.org/v2/{quote(doi)}?email={self.email}"
298
+ try:
299
+ response = self.session.get(url, timeout=10)
300
+ self.stats["api_calls"]["unpaywall"] += 1
301
+
302
+ if response.status_code == 200:
303
+ payload = response.json()
304
+ best_oa = payload.get("best_oa_location")
305
+ if best_oa and best_oa.get("url_for_pdf"):
306
+ return {"pdf_url": best_oa.get("url_for_pdf")}
307
+
308
+ except (requests.RequestException, ValueError, KeyError) as error:
309
+ logger.warning("Unpaywall failed for %s: %s", doi, error)
310
+
311
+ return {}
312
+
313
+ # --- Processing logic --------------------------------------------------------
314
+
315
+ def enrich_record(self, record: dict[str, Any]) -> dict[str, Any]:
316
+ """Aggregate metadata from all providers and fill gaps in one record.
317
+
318
+ Existing fields are never overwritten -- only empty/missing
319
+ fields are populated, so user-curated data always wins.
320
+ """
321
+ self.stats["processed"] += 1
322
+
323
+ doi = self.extract_doi(record)
324
+ title = record.get("title")
325
+
326
+ if not doi and title:
327
+ doi = self.resolve_doi_by_title(str(title))
328
+
329
+ if not doi:
330
+ self.stats["failed"] += 1
331
+ self.failed_records.append(
332
+ {"original_title": title, "reason": "Unresolved DOI via Title Matching"}
333
+ )
334
+ return record
335
+
336
+ enriched_data: dict[str, Any] = {"doi": doi}
337
+
338
+ crossref_metadata = self.fetch_crossref_metadata(doi)
339
+ scholar_metadata = self.fetch_semanticscholar_metadata(doi)
340
+ openalex_metadata = self.fetch_openalex_metadata(doi)
341
+ unpaywall_metadata = self.fetch_unpaywall_pdf(doi)
342
+
343
+ enriched_data.update(crossref_metadata)
344
+ enriched_data.update(scholar_metadata)
345
+ enriched_data.update(openalex_metadata)
346
+
347
+ if unpaywall_metadata.get("pdf_url"):
348
+ enriched_data["pdf_url"] = unpaywall_metadata["pdf_url"]
349
+
350
+ modified = False
351
+ for logical_key, rispy_field in RISPY_FIELD_MAP.items():
352
+ new_value = enriched_data.get(logical_key)
353
+ if not new_value:
354
+ continue
355
+
356
+ if not record.get(rispy_field):
357
+ if rispy_field in _LIST_TYPE_FIELDS:
358
+ record[rispy_field] = (
359
+ new_value if isinstance(new_value, list) else [str(new_value)]
360
+ )
361
+ else:
362
+ record[rispy_field] = (
363
+ new_value if isinstance(new_value, str) else str(new_value)
364
+ )
365
+ modified = True
366
+
367
+ if modified:
368
+ self.stats["enriched"] += 1
369
+
370
+ return record
371
+
372
+ def enrich_file(
373
+ self,
374
+ input_path: str | Path,
375
+ output_path: str | Path,
376
+ fail_report_path: str | Path = "failed_records.json",
377
+ request_delay_seconds: float = 0.1,
378
+ ) -> dict[str, Any]:
379
+ """Enrich every record in a RIS file and write the result.
380
+
381
+ Args:
382
+ input_path: Source ``.ris`` file to enrich.
383
+ output_path: Destination for the enriched ``.ris`` file.
384
+ fail_report_path: Where to write a JSON report of records
385
+ whose DOI could not be resolved. Only written if at
386
+ least one record failed.
387
+ request_delay_seconds: Delay between records, to stay
388
+ within provider rate limits beyond the built-in retry
389
+ policy.
390
+
391
+ Returns:
392
+ The accumulated ``self.stats`` dict.
393
+ """
394
+ input_path = Path(input_path)
395
+ output_path = Path(output_path)
396
+ fail_report_path = Path(fail_report_path)
397
+
398
+ logger.info("Loading %s...", input_path)
399
+ try:
400
+ with input_path.open("r", encoding="utf-8") as file:
401
+ records = list(rispy.load(file))
402
+ except (OSError, TypeError, ValueError) as error:
403
+ logger.error("Failed to parse RIS file: %s", error)
404
+ return self.stats
405
+
406
+ enriched_records = []
407
+ for record in records:
408
+ enriched_records.append(self.enrich_record(record))
409
+ if request_delay_seconds:
410
+ time.sleep(request_delay_seconds)
411
+
412
+ with output_path.open("w", encoding="utf-8") as file:
413
+ rispy.dump(enriched_records, file)
414
+
415
+ if self.failed_records:
416
+ with fail_report_path.open("w", encoding="utf-8") as file:
417
+ json.dump(self.failed_records, file, indent=2)
418
+
419
+ logger.info("Complete. Stats: %s", json.dumps(self.stats, indent=2))
420
+ return self.stats
risforge/exceptions.py ADDED
@@ -0,0 +1,23 @@
1
+ """Exception types for risforge.
2
+
3
+ We deliberately keep this hierarchy small. Most failure modes in this
4
+ package are already well described by the standard library's own
5
+ exceptions (``FileNotFoundError``, ``ValueError``, etc.), and the
6
+ original scripts caught those precisely rather than reaching for
7
+ generic ``Exception``. We keep that pattern. ``RisForgeError`` exists
8
+ only as a common base for the handful of errors that are specific to
9
+ this package's domain logic, so library users can catch
10
+ ``RisForgeError`` if they want a single net for "something in risforge
11
+ itself went wrong" without having to also catch unrelated stdlib
12
+ errors they may want to handle differently.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+
18
+ class RisForgeError(Exception):
19
+ """Base class for errors raised directly by risforge's own logic."""
20
+
21
+
22
+ class RisParsingError(RisForgeError):
23
+ """Raised when a RIS file cannot be parsed into any usable records."""
risforge/pipeline.py ADDED
@@ -0,0 +1,88 @@
1
+ """Orchestrates the clean -> enrich pipeline for a RIS file.
2
+
3
+ This is a generalization of the original ``help.py``. The workflow is
4
+ identical (clean/dedupe, then enrich), but two things changed:
5
+
6
+ 1. File paths are now parameters instead of hardcoded to a specific
7
+ machine's ``/home/<user>/Desktop`` -- a package published to PyPI
8
+ has to run on other people's filesystems.
9
+ 2. It's a plain importable function returning a result object, with
10
+ :mod:`risforge.cli` providing the "run this as a script with these
11
+ fixed paths" behavior that ``help.py`` used to hardcode.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from risforge.cleaning import clean_ris_file
22
+ from risforge.enrichment import RisEnricher
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ @dataclass
28
+ class PipelineResult:
29
+ """Outcome of a full clean+enrich pipeline run."""
30
+
31
+ cleaned_record_count: int
32
+ cleaning_errors: list[tuple[int, str]]
33
+ enrichment_stats: dict[str, Any] = field(default_factory=dict)
34
+
35
+
36
+ def run_pipeline(
37
+ input_path: str | Path,
38
+ dedup_path: str | Path,
39
+ enriched_path: str | Path,
40
+ email: str,
41
+ fail_report_path: str | Path = "failed_records.json",
42
+ ) -> PipelineResult:
43
+ """Run the two-phase clean -> enrich pipeline end to end.
44
+
45
+ Args:
46
+ input_path: Raw ``.ris`` export to process.
47
+ dedup_path: Where the cleaned/deduplicated intermediate file
48
+ is written.
49
+ enriched_path: Where the final enriched ``.ris`` file is
50
+ written.
51
+ email: Contact email passed to Crossref/OpenAlex/Unpaywall
52
+ (required by their usage policies).
53
+ fail_report_path: Where a JSON report of unresolved records is
54
+ written, if any.
55
+
56
+ Returns:
57
+ A :class:`PipelineResult` summarizing both phases.
58
+
59
+ Raises:
60
+ FileNotFoundError: If ``input_path`` does not exist.
61
+ """
62
+ logger.info("Starting RIS bibliographic pipeline...")
63
+
64
+ logger.info("Phase 1: Deduplicating %s", input_path)
65
+ records, errors = clean_ris_file(input_path, dedup_path)
66
+ logger.info("Phase 1 complete: generated %d clean records.", len(records))
67
+ if errors:
68
+ logger.warning("Encountered %d parsing errors during Phase 1.", len(errors))
69
+
70
+ logger.info("Phase 2: Initializing metadata enrichment via APIs")
71
+ enricher = RisEnricher(email=email)
72
+ stats = enricher.enrich_file(
73
+ input_path=dedup_path,
74
+ output_path=enriched_path,
75
+ fail_report_path=fail_report_path,
76
+ )
77
+ logger.info(
78
+ "Phase 2 complete: enriched %d/%d records.",
79
+ stats.get("enriched", 0),
80
+ stats.get("processed", 0),
81
+ )
82
+
83
+ logger.info("Pipeline execution finished successfully.")
84
+ return PipelineResult(
85
+ cleaned_record_count=len(records),
86
+ cleaning_errors=errors,
87
+ enrichment_stats=stats,
88
+ )
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: risforge
3
+ Version: 0.1.0
4
+ Summary: Clean, deduplicate, and enrich RIS bibliographic files for systematic reviews.
5
+ Author-email: Amyr <amyrhexa@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/amyr/risforge
8
+ Project-URL: Repository, https://github.com/amyr/risforge
9
+ Project-URL: Issues, https://github.com/amyr/risforge/issues
10
+ Project-URL: Changelog, https://github.com/amyr/risforge/blob/main/CHANGELOG.md
11
+ Keywords: ris,bibliography,systematic-review,deduplication,crossref,openalex,citation-management,prisma
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Classifier: Topic :: Text Processing :: Filters
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Operating System :: OS Independent
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: rispy>=0.9.0
27
+ Requires-Dist: requests>=2.31
28
+ Requires-Dist: requests-cache>=1.1
29
+ Requires-Dist: urllib3>=2.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=8.0; extra == "dev"
32
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
33
+ Requires-Dist: build>=1.2; extra == "dev"
34
+ Requires-Dist: twine>=5.0; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # risforge
38
+
39
+ **Clean, deduplicate, and enrich RIS bibliographic files for systematic reviews.**
40
+
41
+ `risforge` takes a raw `.ris` export from Scopus, Web of Science, PubMed,
42
+ EndNote, or any other reference manager and:
43
+
44
+ 1. **Cleans & deduplicates** it — normalizing titles, DOIs, and author
45
+ names, then clustering duplicate records (by exact DOI, and by a
46
+ title + first-author fallback) and merging each cluster into one
47
+ complete, data-loss-free record.
48
+ 2. **Enriches** it — filling in missing abstracts, journal names,
49
+ volumes/issues/pages, ISSNs, keywords, and open-access PDF links by
50
+ querying [Crossref](https://www.crossref.org/), [OpenAlex](https://openalex.org/),
51
+ [Semantic Scholar](https://www.semanticscholar.org/), and
52
+ [Unpaywall](https://unpaywall.org/). Existing fields are never
53
+ overwritten — only gaps are filled.
54
+
55
+ It's built for the kind of unglamorous but essential prep work that
56
+ comes before title/abstract screening in a systematic review: getting
57
+ one clean, complete, deduplicated `.ris` file out of a pile of messy,
58
+ overlapping database exports.
59
+
60
+ ## Features
61
+
62
+ - **DOI-first deduplication** with a normalized title + first-author
63
+ fallback for records that lack a DOI, using union-find clustering so
64
+ transitively-linked duplicates across three, four, or more sources
65
+ all collapse into one record.
66
+ - **Zero data loss on merge** — the most complete record in a
67
+ duplicate cluster is used as the base, and every other record in the
68
+ cluster supplements it with whatever fields it's missing.
69
+ - **Non-destructive enrichment** — only empty fields are filled;
70
+ anything you (or an upstream export) already populated is left
71
+ alone.
72
+ - **Resilient HTTP** — automatic retries with exponential backoff on
73
+ 429/5xx responses, and a 7-day on-disk response cache so re-running
74
+ a pipeline doesn't re-hit the same APIs for records you've already
75
+ enriched.
76
+ - **Library or CLI** — use it as `import risforge` in a script/notebook,
77
+ or as a single `risforge` command.
78
+
79
+ ## Installation
80
+
81
+ ```bash
82
+ pip install risforge
83
+ ```
84
+
85
+ Requires Python 3.10+.
86
+
87
+ ## Quick start
88
+
89
+ ### Command line
90
+
91
+ ```bash
92
+ # Clean and deduplicate only
93
+ risforge clean raw_export.ris clean.ris
94
+
95
+ # Enrich an already-clean file
96
+ risforge enrich clean.ris enriched.ris --email you@example.com
97
+
98
+ # Both steps in one call
99
+ risforge pipeline raw_export.ris --email you@example.com
100
+ ```
101
+
102
+ The `pipeline` subcommand writes `<input>_clean.ris` and
103
+ `<input>_enriched.ris` next to your input file by default; pass
104
+ `--dedup-output` / `--output` to control that explicitly.
105
+
106
+ An email address is required by Crossref, OpenAlex, and Unpaywall's
107
+ "polite pool" usage policies — it's sent as a contact address in your
108
+ requests, never stored or transmitted anywhere else.
109
+
110
+ ### Python API
111
+
112
+ ```python
113
+ from risforge import clean_ris_file, RisEnricher, run_pipeline
114
+
115
+ # Clean + deduplicate only
116
+ records, errors = clean_ris_file("raw_export.ris", "clean.ris")
117
+ print(f"{len(records)} unique records, {len(errors)} parse errors")
118
+
119
+ # Enrich only
120
+ enricher = RisEnricher(email="you@example.com")
121
+ stats = enricher.enrich_file("clean.ris", "enriched.ris")
122
+ print(f"Enriched {stats['enriched']}/{stats['processed']} records")
123
+
124
+ # Both, in one call
125
+ result = run_pipeline(
126
+ input_path="raw_export.ris",
127
+ dedup_path="clean.ris",
128
+ enriched_path="enriched.ris",
129
+ email="you@example.com",
130
+ )
131
+ print(result.cleaned_record_count, result.enrichment_stats)
132
+ ```
133
+
134
+ ## Configuration
135
+
136
+ `RisEnricher` accepts a few constructor arguments beyond `email`:
137
+
138
+ ```python
139
+ RisEnricher(
140
+ email="you@example.com",
141
+ cache_name=".api_cache", # base filename for the on-disk HTTP cache
142
+ session=None, # inject your own requests.Session (mainly for testing)
143
+ )
144
+ ```
145
+
146
+ `enrich_file()` also accepts `fail_report_path` (where unresolved-DOI
147
+ records are written as JSON) and `request_delay_seconds` (delay
148
+ between records; defaults to 0.1s to stay within provider rate limits).
149
+
150
+ ## Troubleshooting
151
+
152
+ - **"Unresolved DOI via Title Matching" in the failure report** — the
153
+ record had no DOI and its title didn't match any Crossref result
154
+ above the 90% similarity threshold closely enough to resolve one.
155
+ These records are returned unmodified rather than guessed at.
156
+ - **Enrichment seems slow** — each record makes up to 5 API calls
157
+ (1 for DOI resolution if needed, 4 for metadata) with a small delay
158
+ between records. Re-running against the same input is fast, since
159
+ responses are cached for 7 days.
160
+ - **A field I already had got left alone even though enrichment "ran"** —
161
+ that's intentional. Enrichment only fills empty fields; it never
162
+ overwrites existing data.
163
+
164
+ ## Contributing
165
+
166
+ 1. Clone the repo and install in editable mode with dev dependencies:
167
+ ```bash
168
+ pip install -e ".[dev]"
169
+ ```
170
+ 2. Run the test suite:
171
+ ```bash
172
+ pytest
173
+ ```
174
+ 3. Open a PR. Please include tests for any behavioral change, and note
175
+ any deduplication/merge/enrichment logic changes explicitly in
176
+ `CHANGELOG.md` — this package's core value is *predictability* for
177
+ systematic review workflows, so silent behavior changes are treated
178
+ as bugs.
179
+
180
+ ## License
181
+
182
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,12 @@
1
+ risforge/__init__.py,sha256=laXP4k6nnIaAYIp6imw8HKOnII5QnKH9n0dQnUSlLnw,1035
2
+ risforge/cleaning.py,sha256=au7Kohr1fXvSdltdT4i7clu7Z-W_Wl0ZPVCxP_VJs68,10985
3
+ risforge/cli.py,sha256=UuJqAKOvjJrNv57CZ5CkC72KxQpu_BVM6hD_ZxwZ3M4,6554
4
+ risforge/enrichment.py,sha256=5ty-fa_G_MHWZfq_rTub4jsFNo7UeeXzydpkiIuYres,16520
5
+ risforge/exceptions.py,sha256=azQRv98OEqgAHXgdog68LhbS-607GTSKXLNL8w9WJwI,937
6
+ risforge/pipeline.py,sha256=XF3iaOIggZ_2OP1X9hIpo4WfrZu_KVfLL-K3bZ-Mc08,2919
7
+ risforge-0.1.0.dist-info/licenses/LICENSE,sha256=vQS5uJ3KfJmWzhi4CsbEevI_26nx1RxO3hnUWb5D9yA,1061
8
+ risforge-0.1.0.dist-info/METADATA,sha256=Ml38gRN-0o38uWgw76oHy8WzvbYZWc3ZV_QE0YNkAYI,6682
9
+ risforge-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ risforge-0.1.0.dist-info/entry_points.txt,sha256=POs_X6Bu61aO5qoaA__Fll1dKcUl2rvB50QZpAcb6ss,47
11
+ risforge-0.1.0.dist-info/top_level.txt,sha256=Xo-Ia33NjkuMGYmEMLAuHpOxYOMYfMa8SOFbyUXwE7k,9
12
+ risforge-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ risforge = risforge.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amyr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ risforge