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 +38 -0
- risforge/cleaning.py +318 -0
- risforge/cli.py +197 -0
- risforge/enrichment.py +420 -0
- risforge/exceptions.py +23 -0
- risforge/pipeline.py +88 -0
- risforge-0.1.0.dist-info/METADATA +182 -0
- risforge-0.1.0.dist-info/RECORD +12 -0
- risforge-0.1.0.dist-info/WHEEL +5 -0
- risforge-0.1.0.dist-info/entry_points.txt +2 -0
- risforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- risforge-0.1.0.dist-info/top_level.txt +1 -0
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,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
|