@natlibfi/melinda-record-matching 5.0.4 → 5.0.5
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.
- package/.github/workflows/melinda-node-tests-and-publish.yml +4 -4
- package/dist/candidate-search/query-list/bib.js +84 -67
- package/dist/candidate-search/query-list/bib.js.map +3 -3
- package/dist/candidate-search/query-list/component.js +94 -28
- package/dist/candidate-search/query-list/component.js.map +3 -3
- package/dist/cli.js +12 -4
- package/dist/cli.js.map +2 -2
- package/dist/match-detection/features/bib/f773.js +82 -0
- package/dist/match-detection/features/bib/f773.js.map +7 -0
- package/dist/match-detection/features/bib/index.js +1 -1
- package/dist/match-detection/features/bib/index.js.map +2 -2
- package/dist/match-detection/features/bib/index.test.js +1 -1
- package/dist/match-detection/features/bib/index.test.js.map +2 -2
- package/dist/match-detection/features/bib/isbn.js +3 -13
- package/dist/match-detection/features/bib/isbn.js.map +2 -2
- package/dist/match-detection/features/bib/issn.js +2 -16
- package/dist/match-detection/features/bib/issn.js.map +2 -2
- package/dist/match-detection/features/bib/language.js +12 -22
- package/dist/match-detection/features/bib/language.js.map +2 -2
- package/dist/match-detection/features/bib/publication-time-allow-cons-years.js +6 -2
- package/dist/match-detection/features/bib/publication-time-allow-cons-years.js.map +2 -2
- package/dist/match-detection/features/bib/publication-time.js +62 -16
- package/dist/match-detection/features/bib/publication-time.js.map +2 -2
- package/dist/match-detection/features/bib/title.js +76 -26
- package/dist/match-detection/features/bib/title.js.map +3 -3
- package/dist/match-detection/index.js +8 -3
- package/dist/match-detection/index.js.map +2 -2
- package/dist/matching-utils.js +14 -0
- package/dist/matching-utils.js.map +2 -2
- package/package.json +8 -12
- package/src/candidate-search/query-list/bib.js +100 -77
- package/src/candidate-search/query-list/component.js +100 -23
- package/src/cli.js +8 -4
- package/src/match-detection/features/bib/f773.js +118 -0
- package/src/match-detection/features/bib/index.js +1 -1
- package/src/match-detection/features/bib/index.test.js +1 -1
- package/src/match-detection/features/bib/isbn.js +3 -17
- package/src/match-detection/features/bib/issn.js +3 -32
- package/src/match-detection/features/bib/language.js +15 -26
- package/src/match-detection/features/bib/publication-time-allow-cons-years.js +7 -4
- package/src/match-detection/features/bib/publication-time.js +76 -30
- package/src/match-detection/features/bib/title.js +112 -34
- package/src/match-detection/index.js +9 -3
- package/src/matching-utils.js +17 -0
- package/dist/match-detection/features/bib/title-version-original.js +0 -37
- package/dist/match-detection/features/bib/title-version-original.js.map +0 -7
- package/src/match-detection/features/bib/title-version-original.js +0 -52
|
@@ -1,18 +1,5 @@
|
|
|
1
|
-
/*
|
|
2
|
-
//import createInterface from './standard-identifier-factory.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export default () => {
|
|
6
|
-
const {extract, compare} = createInterface({pattern: /^022$/u, subfieldCodes: ['a', 'z', 'y']});
|
|
7
|
-
return {extract, compare, name: 'ISSN'};
|
|
8
|
-
};
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
|
|
12
1
|
import createDebugLogger from 'debug';
|
|
13
2
|
|
|
14
|
-
import {isComponentRecord} from '@natlibfi/melinda-commons';
|
|
15
|
-
|
|
16
3
|
const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features:issn');
|
|
17
4
|
const debugData = debug.extend('data');
|
|
18
5
|
|
|
@@ -21,18 +8,16 @@ export default () => ({
|
|
|
21
8
|
extract: ({record/*, recordExternal*/}) => {
|
|
22
9
|
//const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
|
|
23
10
|
|
|
24
|
-
const isHost = !isComponentRecord(record, false, []);
|
|
25
|
-
|
|
26
|
-
// debug(`\t Is HOST: '${isHost}'`);
|
|
27
11
|
return getIssns();
|
|
28
12
|
|
|
29
13
|
function getIssns() {
|
|
30
|
-
const fields =
|
|
14
|
+
const fields = record.get(/^022$/u);
|
|
31
15
|
if (fields.length === 0) {
|
|
32
16
|
return [];
|
|
33
17
|
}
|
|
18
|
+
|
|
34
19
|
debug(`\t ${fields.length} potential ISSN fields (${fields[0].tag})`);
|
|
35
|
-
const subfieldCodes =
|
|
20
|
+
const subfieldCodes = ['a', 'y', 'z']
|
|
36
21
|
//debug(`\t subfield codes: '${subfieldCodes.join("', '")}'`);
|
|
37
22
|
const subfieldValues = fields.flatMap(f => f.subfields.filter(sf => subfieldCodes.includes(sf.code)).map(sf => sf.value));
|
|
38
23
|
//debug(`\t cand values: '${subfieldValues.join("', '")}'`);
|
|
@@ -44,13 +29,6 @@ export default () => ({
|
|
|
44
29
|
return uniqArray(validSubfieldValues);
|
|
45
30
|
}
|
|
46
31
|
|
|
47
|
-
function getRelevantFields() {
|
|
48
|
-
if (isHost) {
|
|
49
|
-
return record.get(/^022$/u);
|
|
50
|
-
}
|
|
51
|
-
return record.get(/^[79]73$/u);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
32
|
function normalizeSubfieldValue(val) {
|
|
55
33
|
return val.replace(/[., :;].*$/u, '');
|
|
56
34
|
}
|
|
@@ -58,13 +36,6 @@ export default () => ({
|
|
|
58
36
|
function isValidIssn(val) {
|
|
59
37
|
return /^[0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9X]$/u.test(val);
|
|
60
38
|
}
|
|
61
|
-
|
|
62
|
-
function getRelevantSubfieldCodes() {
|
|
63
|
-
if (isHost) {
|
|
64
|
-
return ['a', 'y', 'z'];
|
|
65
|
-
}
|
|
66
|
-
return ['x'];
|
|
67
|
-
}
|
|
68
39
|
},
|
|
69
40
|
// eslint-disable-next-line max-statements
|
|
70
41
|
compare: (aa, bb) => {
|
|
@@ -31,10 +31,11 @@ export default () => ({
|
|
|
31
31
|
|
|
32
32
|
FixSami041().fix(clonedRecord); // Handle 'smi' adding if needed
|
|
33
33
|
Remove041zxx().fix(clonedRecord); // Remove 'zxx' from f041s
|
|
34
|
+
clonedRecord.fields = clonedRecord.fields.filter(f => ['008', '041'].includes(f.tag));
|
|
34
35
|
// Add other language code normalizations?
|
|
35
36
|
|
|
36
37
|
// Should we return all the fields, or just the relevant fields?
|
|
37
|
-
return [
|
|
38
|
+
return [clonedRecord.fields, label];
|
|
38
39
|
},
|
|
39
40
|
// eslint-disable-next-line max-statements
|
|
40
41
|
compare: (aa, bb) => {
|
|
@@ -50,9 +51,6 @@ export default () => ({
|
|
|
50
51
|
if (score > 0.1) { // Keeps the original max, we might have 0.10 (041) + 0.05 (008/35-37) = 0.15 now
|
|
51
52
|
return 0.1;
|
|
52
53
|
}
|
|
53
|
-
if (score < -1) {
|
|
54
|
-
return -1.0;
|
|
55
|
-
}
|
|
56
54
|
return score;
|
|
57
55
|
}
|
|
58
56
|
|
|
@@ -73,7 +71,7 @@ export default () => ({
|
|
|
73
71
|
return 0.05;
|
|
74
72
|
}
|
|
75
73
|
|
|
76
|
-
if (a008 === 'mul' || b008 === 'mul') {
|
|
74
|
+
if (a008 === 'mul' || b008 === 'mul') { // We'll let 041 decide
|
|
77
75
|
return 0.0;
|
|
78
76
|
}
|
|
79
77
|
|
|
@@ -87,6 +85,7 @@ export default () => ({
|
|
|
87
85
|
function compare041() {
|
|
88
86
|
const a041s = getFields041(a);
|
|
89
87
|
const b041s = getFields041(b);
|
|
88
|
+
|
|
90
89
|
if (a041s.length === 0 || b041s.length === 0) {
|
|
91
90
|
return 0.0; // Should we punish these for badness?
|
|
92
91
|
}
|
|
@@ -97,14 +96,14 @@ export default () => ({
|
|
|
97
96
|
const scoreInd1 = mainPenalty === 0.0 ? 0.0 : indicator1Penalty(a041s[0], b041s[0]);
|
|
98
97
|
return mainPenalty + sourcePenalty + scoreInd1;
|
|
99
98
|
}
|
|
100
|
-
// Things are already complicated (likely to pe penalized, so no nedd to
|
|
99
|
+
// Things are already complicated (likely to pe penalized, so no nedd to worry about language code sources, I daresay.
|
|
101
100
|
return compareLanguageCodeLists(getLanguageCodesFrom041Fields(a), getLanguageCodesFrom041Fields(b));
|
|
102
101
|
|
|
103
|
-
function getFields041(
|
|
104
|
-
if (!
|
|
102
|
+
function getFields041(fields) {
|
|
103
|
+
if (!fields) {
|
|
105
104
|
return [];
|
|
106
105
|
}
|
|
107
|
-
return
|
|
106
|
+
return fields.filter(f => f.tag === '041');
|
|
108
107
|
}
|
|
109
108
|
|
|
110
109
|
}
|
|
@@ -139,7 +138,7 @@ export default () => ({
|
|
|
139
138
|
}
|
|
140
139
|
|
|
141
140
|
function compareLanguageCodeLists(a, b) {
|
|
142
|
-
if (a.length === 0 || b.length === 0) {
|
|
141
|
+
if (a.length === 0 || b.length === 0 || a.every(val => containsNoData(val)) || b.every(val => containsNoData(val))) {
|
|
143
142
|
debugData(`No language to compare`);
|
|
144
143
|
return 0;
|
|
145
144
|
}
|
|
@@ -176,19 +175,9 @@ export default () => ({
|
|
|
176
175
|
|
|
177
176
|
|
|
178
177
|
// Damage control:
|
|
179
|
-
|
|
180
|
-
// Not using the generic solution here as eg. 'und' needs a special treatment
|
|
181
|
-
const sharedValues = getSharedValues(a, b);
|
|
182
|
-
const aOnly = a.filter(val => !sharedValues.includes(val));
|
|
183
|
-
const bOnly = b.filter(val => !sharedValues.includes(val));
|
|
184
|
-
const hasUnd = [...aOnly, ...bOnly].includes('und');
|
|
178
|
+
const sharedValues = getSharedValues(a, b).filter(val => !containsNoData(val));
|
|
185
179
|
|
|
186
180
|
if (sharedValues.length < 1) {
|
|
187
|
-
console.info(`NV: ${sharedValues.join(", ")}`);
|
|
188
|
-
if (aOnly.length === 1 && bOnly.length === 1 && hasUnd) {
|
|
189
|
-
debug(`Both have languages, but none of these match. However, the benefit of doubt is given: '${aOnly[0]}' and '${bOnly[0]}' might mean the same}`);
|
|
190
|
-
return 0;
|
|
191
|
-
}
|
|
192
181
|
debug(`Both have languages, but none of these match.`); // includes 'mul' vs a single language code
|
|
193
182
|
return -1.0;
|
|
194
183
|
}
|
|
@@ -218,11 +207,11 @@ export default () => ({
|
|
|
218
207
|
|
|
219
208
|
});
|
|
220
209
|
|
|
221
|
-
function get008Value(
|
|
222
|
-
if (!
|
|
210
|
+
function get008Value(fields, label = 'record') {
|
|
211
|
+
if (!fields) {
|
|
223
212
|
return;
|
|
224
213
|
}
|
|
225
|
-
const f008 =
|
|
214
|
+
const f008 = fields.find(f => f.tag === '008');
|
|
226
215
|
if (!f008) {
|
|
227
216
|
return undefined;
|
|
228
217
|
}
|
|
@@ -269,12 +258,12 @@ function isLangCodeForALanguage(code, label = 'record', encoding = undefined) {
|
|
|
269
258
|
|
|
270
259
|
|
|
271
260
|
|
|
272
|
-
function getLanguageCodesFrom041Fields(
|
|
261
|
+
function getLanguageCodesFrom041Fields(fields) {
|
|
273
262
|
// NB! We brutally don't check $2 (language code source) as marc's language codes is practically a subset of ISO 639-2,
|
|
274
263
|
// and also ISO 639-2 and ISO 639-3 overlap to a degree. If we ever run into a trouble with some ISO 639-2 vs 639-3 mismatch, then we'll work it out.
|
|
275
264
|
// Also we could write a validator that converts ISO 639-2 to marc if applicable and maybe even partial support for ISO-639-3.
|
|
276
265
|
// Note that ISO 639-2-B values correspond with marc beteer that ISO 639-2-T. Eg. code for Chinese 'zho' should/code be normalized to 'chi'!
|
|
277
|
-
const values =
|
|
266
|
+
const values = fields.filter(f => f.tag === '041').flatMap(f => getFieldsLanguageCodes(f));
|
|
278
267
|
/*
|
|
279
268
|
// .filter(({ind2}) => ind2 === ' ')
|
|
280
269
|
.map(({subfields}) => subfields)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
import createDebugLogger from 'debug';
|
|
3
3
|
import {testStringOrNumber} from '../../../matching-utils.js';
|
|
4
|
+
import {matchingYears} from './publication-time.js';
|
|
4
5
|
|
|
5
6
|
// We should also get copyright time and copyright/publication times from 26x
|
|
6
7
|
|
|
@@ -16,14 +17,16 @@ export default () => ({
|
|
|
16
17
|
|
|
17
18
|
const [firstA] = a;
|
|
18
19
|
const [firstB] = b;
|
|
20
|
+
// Sanity check: If either of years is a non string/number, values are not comparable
|
|
21
|
+
if (!testStringOrNumber(firstA) || !testStringOrNumber(firstB)) {
|
|
22
|
+
return 0.0;
|
|
23
|
+
}
|
|
19
24
|
|
|
20
25
|
if (firstA === firstB) {
|
|
21
26
|
return 0.1;
|
|
22
27
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (!testStringOrNumber(firstA) || !testStringOrNumber(firstB)) {
|
|
26
|
-
return 0;
|
|
28
|
+
if (matchingYears(firstA, firstB)) { // Handle 'u' Eg. '18uu' vs '1812'
|
|
29
|
+
return 0.05;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
const firstANumber = parseInt(firstA, 10);
|
|
@@ -1,18 +1,23 @@
|
|
|
1
|
+
// We should also get copyright time and copyright/publication times from 26x
|
|
2
|
+
// see publication-time-allow-cons-years for a version allowing consequent years to match
|
|
1
3
|
|
|
4
|
+
import {extractPublicationYearFrom773} from '../../../candidate-search/query-list/component.js';
|
|
5
|
+
|
|
6
|
+
const MAX = 0.1;
|
|
7
|
+
const MIN = -1.0;
|
|
2
8
|
|
|
3
|
-
import {testStringOrNumber} from '../../../matching-utils.js';
|
|
4
9
|
|
|
5
|
-
// We should also get copyright time and copyright/publication times from 26x
|
|
6
|
-
// see publication-time-allow-cons-years for a version allowing consequent years to match
|
|
7
10
|
|
|
8
11
|
export default () => ({
|
|
9
12
|
name: 'Publication time',
|
|
10
13
|
extract: ({record}) => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
let dateData = getDataFrom008();
|
|
15
|
+
const [f773] = record.get(/^773$/u);
|
|
16
|
+
const hostYear = f773 && extractPublicationYearFrom773(f773) || null;
|
|
17
|
+
dateData.hostYear = hostYear;
|
|
18
|
+
return dateData;
|
|
14
19
|
|
|
15
|
-
function
|
|
20
|
+
function getDataFrom008() {
|
|
16
21
|
const [f008] = record.get(/^008$/u);
|
|
17
22
|
if (!f008 || f008.value.length < 16) {
|
|
18
23
|
return splitDateData('| ');
|
|
@@ -29,50 +34,91 @@ export default () => ({
|
|
|
29
34
|
},
|
|
30
35
|
|
|
31
36
|
compare: (aa, bb) => {
|
|
32
|
-
|
|
37
|
+
// Be happy with a f773$g match:
|
|
38
|
+
if (aa.hostYear && bb.hostYear && aa.hostYear === bb.hostYear) {
|
|
39
|
+
return MAX;
|
|
40
|
+
}
|
|
41
|
+
// Check 008
|
|
42
|
+
if (aa.typeOfDate === 'b') { // 008/06: Before Christ. No really makes sense in our domain, though.
|
|
33
43
|
if (bb.typeOfDate === 'b') {
|
|
34
44
|
return 0;
|
|
35
45
|
}
|
|
36
|
-
return
|
|
46
|
+
return MIN;
|
|
37
47
|
}
|
|
38
48
|
if (aa.typeOfDate === 'n' || bb.typeOfDate === 'n') { // n=unknown
|
|
39
49
|
return 0;
|
|
40
50
|
}
|
|
41
51
|
|
|
52
|
+
// Try to handle questionable dates:
|
|
53
|
+
if (aa.typeOfDate === 'q') { // questionable data
|
|
54
|
+
if (bb.typeOfDate !== 'q') {
|
|
55
|
+
if (yearInBetween(aa.date1, bb.date1, aa.date2)) {
|
|
56
|
+
return MAX;
|
|
57
|
+
}
|
|
58
|
+
return MIN;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// What if there are questionable dates on both sides?
|
|
62
|
+
if (aa.date1 === bb.date1 && bb.date1 === bb.date2) {
|
|
63
|
+
return MAX;
|
|
64
|
+
}
|
|
65
|
+
// Lazily return 0. (We could check for overlap etc. but not really worth the effort)
|
|
66
|
+
return 0.0;
|
|
67
|
+
}
|
|
68
|
+
if (bb.typeOfDate === 'q') {
|
|
69
|
+
if (yearInBetween(bb.date1, aa.date1, bb.date2)) {
|
|
70
|
+
return MAX;
|
|
71
|
+
}
|
|
72
|
+
return MIN;
|
|
73
|
+
}
|
|
74
|
+
|
|
42
75
|
const skipList = [' ', '||||', 'uuuu'];
|
|
43
76
|
if (skipList.includes(aa.date1) || skipList.includes(bb.date1)) { // 008/07-10 carries no information
|
|
44
77
|
return 0;
|
|
45
78
|
}
|
|
46
79
|
|
|
47
80
|
if (matchingYears(aa.date1, bb.date1)) { // 'u' support
|
|
48
|
-
return
|
|
81
|
+
return MAX;
|
|
49
82
|
}
|
|
50
83
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
function matchingYears(yyyy1, yyyy2) {
|
|
56
|
-
if (yyyy1.length === 0) { // All digits have been succesfully consumed -> success
|
|
57
|
-
return true;
|
|
58
|
-
}
|
|
59
|
-
if (yyyy1[0] === 'u' || yyyy2[0] === 'u') { // Ignore 'u' (it refers to unknown millenia, century, decade or year)
|
|
60
|
-
return matchingYears(yyyy1.slice(1), yyyy2.slice(1));
|
|
61
|
-
}
|
|
62
|
-
if (yyyy1[0] !== yyyy2[0]) {
|
|
63
|
-
return false;
|
|
84
|
+
if (isValidYear(aa.date1) && bb.date1) {
|
|
85
|
+
const difference = Math.abs(aa.date1 - bb.date1);
|
|
86
|
+
if (difference === 1) {
|
|
87
|
+
return MIN/8;
|
|
64
88
|
}
|
|
65
|
-
// Should we require that yyyy[0] is a digit at this point?
|
|
66
|
-
return matchingYears(yyyy1.slice(1), yyyy2.slice(1));
|
|
67
89
|
}
|
|
68
90
|
|
|
69
|
-
|
|
70
|
-
function hasFourDigits(yyyy) { // Will be needed by 'q' support
|
|
71
|
-
return yyyy.match(/^[0-9]{4}$/u);
|
|
72
|
-
}
|
|
73
|
-
*/
|
|
91
|
+
return MIN;
|
|
74
92
|
}
|
|
75
93
|
|
|
76
94
|
});
|
|
77
95
|
|
|
96
|
+
function yearInBetween(start, curr, end) {
|
|
97
|
+
if (!isValidYear(start) || !isValidYear(curr) || !isValidYear(end)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
return start <= curr && curr <= end;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function matchingYears(yyyy1, yyyy2) {
|
|
104
|
+
if (yyyy1.length === 0) { // All digits have been succesfully consumed -> success
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
if (yyyy1[0] === 'u' || yyyy2[0] === 'u') { // Ignore 'u' (it refers to unknown millenia, century, decade or year)
|
|
108
|
+
return matchingYears(yyyy1.slice(1), yyyy2.slice(1));
|
|
109
|
+
}
|
|
110
|
+
if (yyyy1[0] !== yyyy2[0]) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
if (yyyy1[0] >= '0' && yyyy1[0] <= '9') { // Require that yyyy[0] is a digit at this point? (What if year is 983?)
|
|
114
|
+
return matchingYears(yyyy1.slice(1), yyyy2.slice(1));
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
const validYearRegexp = /^(?:1[89][0-9][0-9]|20[012][0-9])$/u;
|
|
78
121
|
|
|
122
|
+
function isValidYear(yyyy) {
|
|
123
|
+
return validYearRegexp.test(yyyy); // Currently supports 1800-2029
|
|
124
|
+
}
|
|
@@ -3,19 +3,28 @@ import naturalPkg from 'natural';
|
|
|
3
3
|
const {LevenshteinDistance: leven} = naturalPkg;
|
|
4
4
|
import {testStringOrNumber} from '../../../matching-utils.js';
|
|
5
5
|
|
|
6
|
-
const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features:title');
|
|
7
|
-
const debugData = debug.extend('data');
|
|
8
6
|
|
|
7
|
+
const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features/bib/title');
|
|
8
|
+
const debugData = debug.extend('data');
|
|
9
9
|
|
|
10
|
-
export default ({threshold =
|
|
10
|
+
export default ({threshold = 0.9} = {}) => ({
|
|
11
11
|
name: 'Title',
|
|
12
12
|
extract: ({record, recordExternal}) => {
|
|
13
13
|
const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
|
|
14
|
-
const
|
|
15
|
-
debug(`${label}: title: ${
|
|
14
|
+
const a = getTitle(record, ['a']);
|
|
15
|
+
debug(`${label}: title: ${a}`);
|
|
16
|
+
|
|
17
|
+
if (a) {
|
|
18
|
+
const b = normalizeTitle(getTitle(record, ['b'])) || '';
|
|
19
|
+
const n = normalizeTitle(getTitle(record, ['n'])) || '';
|
|
20
|
+
const p = normalizeTitle(getTitle(record, ['p'])) || '';
|
|
21
|
+
return [normalizeTitle(a), b, n, p];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return ['', '', '', ''];
|
|
16
25
|
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
function normalizeTitle(title) {
|
|
27
|
+
return title
|
|
19
28
|
// decompose unicode diacritics
|
|
20
29
|
.normalize('NFD')
|
|
21
30
|
// strip non-letters/numbers
|
|
@@ -24,48 +33,117 @@ export default ({threshold = 10} = {}) => ({
|
|
|
24
33
|
// - see validator normalize-utf8-diacritics for details
|
|
25
34
|
.replace(/[^\p{Letter}\p{Number}]/gu, '')
|
|
26
35
|
.toLowerCase();
|
|
27
|
-
debug(`${label}: titleString: ${titleAsNormalizedString}`);
|
|
28
|
-
return [titleAsNormalizedString];
|
|
29
36
|
}
|
|
30
37
|
|
|
31
|
-
return [];
|
|
32
|
-
|
|
33
|
-
function getTitle() {
|
|
34
|
-
const [field] = record.get(/^245$/u);
|
|
35
|
-
debugData(`${label}: titleField: ${JSON.stringify(field)}`);
|
|
36
|
-
|
|
37
|
-
if (field) {
|
|
38
|
-
return field.subfields
|
|
39
|
-
// get also $n:s and $p:s here
|
|
40
|
-
.filter(({code}) => ['a', 'b', 'n', 'p'].includes(code))
|
|
41
|
-
.map(({value}) => testStringOrNumber(value) ? String(value) : '')
|
|
42
|
-
.join('');
|
|
43
|
-
}
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
38
|
},
|
|
47
39
|
compare: (a, b) => {
|
|
48
|
-
const
|
|
49
|
-
const
|
|
40
|
+
const [aa, ab, an, ap] = a;
|
|
41
|
+
const [ba, bb, bn, bp] = b;
|
|
42
|
+
|
|
43
|
+
if (isEmpty(aa) || isEmpty(ba)) {
|
|
44
|
+
return -1.0;
|
|
45
|
+
}
|
|
46
|
+
// F245$n information is critical; it can not mismatch at all:
|
|
47
|
+
if (an.length && bn.length && an !== bn) { // If these exists, they must be the same (we might convert Roman numbers to Arabic numbers though)
|
|
48
|
+
return -1.0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const aFull = toFullTitle(a);
|
|
52
|
+
const bFull = toFullTitle(b);
|
|
53
|
+
|
|
54
|
+
const [distance, maxLength, correctness] = doLevenshtein(aFull, bFull);
|
|
55
|
+
|
|
56
|
+
debug(`'${aFull}' vs '${bFull}': Max length = ${maxLength}, distance = ${distance}, correctness = ${correctness}`);
|
|
50
57
|
|
|
51
58
|
if (distance === 0) {
|
|
52
59
|
return 0.5;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
if (correctness >= threshold) {
|
|
63
|
+
return 0.4;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (an && bn) {
|
|
67
|
+
// There seems to be some wobble between $b and $p, for example:
|
|
68
|
+
if (ab && isEmpty(ap) && bp && isEmpty(bb)) {
|
|
69
|
+
return compare([aa, ap, an, ab], [ba, bb, bn, bp]);
|
|
70
|
+
}
|
|
71
|
+
if (ap && isEmpty(ab) && bb && isEmpty(bp)) {
|
|
72
|
+
return compare([aa, ab, an, ap], [ba, bp, bn, bb]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Try the same without $p:
|
|
77
|
+
if (localXor(ap, bp)) {
|
|
78
|
+
const result = compare([aa, ab, an, ''], [ba, bb, bn, '']);
|
|
79
|
+
return result > 0.0 ? result * 0.8 : result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (isEmpty(ap) && isEmpty(bp) && localXor(ab, bb)) {
|
|
83
|
+
// Try the same without $b ($p is not here)
|
|
84
|
+
const result = compare([aa, '', an, ''], [ba, '', bn, '']);
|
|
85
|
+
return result > 0.0 ? result * 0.8 : result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return -0.5; // Not likely
|
|
89
|
+
|
|
90
|
+
function isEmpty(x) {
|
|
91
|
+
return !x || x.length === 0;
|
|
92
|
+
}
|
|
57
93
|
|
|
58
|
-
|
|
94
|
+
function localXor(x, y) {
|
|
95
|
+
if (isEmpty(x)) {
|
|
96
|
+
return !isEmpty(y);
|
|
97
|
+
}
|
|
98
|
+
// 'x' exists, thus 'y' can not exist:
|
|
99
|
+
return isEmpty(y);
|
|
100
|
+
}
|
|
59
101
|
|
|
60
|
-
|
|
61
|
-
|
|
102
|
+
function doLevenshtein(string1, string2) {
|
|
103
|
+
const distance = leven(string1, string2);
|
|
104
|
+
const len = getMaxLength(string1, string2);
|
|
105
|
+
const correctness = 1.0 - (distance / len);
|
|
106
|
+
return [distance, len, correctness];
|
|
62
107
|
}
|
|
63
108
|
|
|
64
|
-
|
|
109
|
+
function toFullTitle(arr) {
|
|
110
|
+
const relevant = arr.filter(val => typeof val === 'string' && val.length);
|
|
111
|
+
return relevant.join(' ');
|
|
112
|
+
}
|
|
65
113
|
|
|
66
|
-
function getMaxLength() {
|
|
67
|
-
return
|
|
114
|
+
function getMaxLength(str1, str2) {
|
|
115
|
+
return str1.length > str2.length ? str1.length : str2.length;
|
|
68
116
|
}
|
|
69
117
|
|
|
70
118
|
}
|
|
71
119
|
});
|
|
120
|
+
|
|
121
|
+
export function getTitle(record, targetSubfieldCodes = ['a', 'b', 'n', 'p']) {
|
|
122
|
+
const [field] = record.get(/^245$/u);
|
|
123
|
+
debugData(`titleField: ${JSON.stringify(field)}`);
|
|
124
|
+
|
|
125
|
+
if (field) {
|
|
126
|
+
const title = field.subfields
|
|
127
|
+
// get also $n:s and $p:s here
|
|
128
|
+
.filter(({code}) => targetSubfieldCodes.includes(code))
|
|
129
|
+
// Would be nice to normalize $n values...
|
|
130
|
+
.map(({value}) => testStringOrNumber(value) ? String(value) : '')
|
|
131
|
+
.join(' ')
|
|
132
|
+
.replace(/\[[^\]]*\]/ug, ' ') // Remove [whatever] stuff. ADD TEST FOR THIS
|
|
133
|
+
.replace(/ [=\/:](?:$| )/ug, ' ') // Strip punctuation
|
|
134
|
+
// Also đ vs d pairs seen:
|
|
135
|
+
//.replace(/(?:đ|ȧ|t̄|ǩ|ǧ|s̆|c̆)/ug, '') // Hack. Saamelaisbibliografia has often dropped this wierd characters (oft old articles, no longer used in sami either)
|
|
136
|
+
// trim:
|
|
137
|
+
.replace(/ +/ug, ' ')
|
|
138
|
+
.replace(/^ +/u, '')
|
|
139
|
+
.replace(/ +$/u, '');
|
|
140
|
+
|
|
141
|
+
// Skip non-filing indicator (note that '9' is a magic indicator value, so we don't do it):
|
|
142
|
+
if (/^[1-8]$/u.test(field.ind2)) { // Skip non-filing characters
|
|
143
|
+
return title.slice(parseInt(field.ind2));
|
|
144
|
+
}
|
|
145
|
+
return title;
|
|
146
|
+
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
@@ -5,7 +5,8 @@ import * as features from './features/index.js';
|
|
|
5
5
|
export {features};
|
|
6
6
|
|
|
7
7
|
export default ({strategy, threshold = 0.8999}, returnStrategy = false, localSettings = {}) => ({recordA, recordB, recordAExternal = {}, recordBExternal = {}}) => {
|
|
8
|
-
const
|
|
8
|
+
const MIN_PROPABILITY_QUALIFIER = 0.4; // Title perfect match: 0.5
|
|
9
|
+
const FORCE_FAIL = -1.0;
|
|
9
10
|
|
|
10
11
|
const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection');
|
|
11
12
|
const debugData = debug.extend('data');
|
|
@@ -41,13 +42,18 @@ export default ({strategy, threshold = 0.8999}, returnStrategy = false, localSet
|
|
|
41
42
|
const featurePairs = generateFeaturePairs(featuresA, featuresB);
|
|
42
43
|
const similarityVector = generateSimilarityVector(featurePairs);
|
|
43
44
|
|
|
44
|
-
if (similarityVector.some(v => v >=
|
|
45
|
+
if (similarityVector.some(v => v >= MIN_PROPABILITY_QUALIFIER)) {
|
|
45
46
|
const probability = calculateProbability(similarityVector);
|
|
46
47
|
debug(`probability: ${probability} (Threshold: ${threshold})`);
|
|
48
|
+
if (similarityVector.some(v => v <= FORCE_FAIL)) {
|
|
49
|
+
debug(`Some feature resulted in utter failure (${FORCE_FAIL} or less)`);
|
|
50
|
+
return returnResult({match: false, probability: 0.0});
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
return returnResult({match: probability >= threshold, probability});
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
debugData(`No feature yielded minimum probability amount of points (${
|
|
56
|
+
debugData(`No feature yielded minimum probability amount of points (${MIN_PROPABILITY_QUALIFIER})`);
|
|
51
57
|
return returnResult({match: false, probability: 0.0});
|
|
52
58
|
}
|
|
53
59
|
|
package/src/matching-utils.js
CHANGED
|
@@ -112,3 +112,20 @@ export function getMatchCounts(aValues, bValues) {
|
|
|
112
112
|
return aToB < bToA ? aToB : bToA;
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
export function stringAfter(string, substring) {
|
|
118
|
+
const pos = string.indexOf(substring);
|
|
119
|
+
if (pos === -1) {
|
|
120
|
+
return string;
|
|
121
|
+
}
|
|
122
|
+
return string.substring(pos+substring.length);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function stringBefore(string, substring) {
|
|
126
|
+
const pos = string.indexOf(substring);
|
|
127
|
+
if (pos === -1) {
|
|
128
|
+
return string;
|
|
129
|
+
}
|
|
130
|
+
return string.substring(0, pos);
|
|
131
|
+
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import createDebugLogger from "debug";
|
|
2
|
-
import naturalPkg from "natural";
|
|
3
|
-
const { LevenshteinDistance: leven } = naturalPkg;
|
|
4
|
-
export default ({ threshold = 10 } = {}) => ({
|
|
5
|
-
name: "titleVersionOriginal",
|
|
6
|
-
extract: ({ record }) => {
|
|
7
|
-
const title = getTitle();
|
|
8
|
-
if (title) {
|
|
9
|
-
return [title.replace(/[^\p{Letter}\p{Number}]/gu, "").toLowerCase()];
|
|
10
|
-
}
|
|
11
|
-
return [];
|
|
12
|
-
function getTitle() {
|
|
13
|
-
const [field] = record.get(/^245$/u);
|
|
14
|
-
if (field) {
|
|
15
|
-
return field.subfields.filter(({ code }) => ["a", "b"].includes(code)).map(({ value }) => value).join("");
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
},
|
|
19
|
-
compare: (a, b) => {
|
|
20
|
-
const debug = createDebugLogger("@natlibfi/melinda-record-matching:match-detection:features/bib/title-version-original");
|
|
21
|
-
const distance = leven(a[0], b[0]);
|
|
22
|
-
if (distance === 0) {
|
|
23
|
-
return 0.5;
|
|
24
|
-
}
|
|
25
|
-
const maxLength = getMaxLength();
|
|
26
|
-
const percentage = distance / maxLength * 100;
|
|
27
|
-
debug(`'${a}' vs '${b}': Max length = ${maxLength}, distance = ${distance}, percentage = ${percentage}`);
|
|
28
|
-
if (percentage <= threshold) {
|
|
29
|
-
return 0.3;
|
|
30
|
-
}
|
|
31
|
-
return -0.5;
|
|
32
|
-
function getMaxLength() {
|
|
33
|
-
return a[0].length > b[0].length ? a[0].length : b[0].length;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
//# sourceMappingURL=title-version-original.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../../src/match-detection/features/bib/title-version-original.js"],
|
|
4
|
-
"sourcesContent": ["\nimport createDebugLogger from 'debug';\nimport naturalPkg from 'natural';\nconst {LevenshteinDistance: leven} = naturalPkg;\n\nexport default ({threshold = 10} = {}) => ({\n name: 'titleVersionOriginal',\n extract: ({record}) => {\n const title = getTitle();\n\n if (title) {\n return [title.replace(/[^\\p{Letter}\\p{Number}]/gu, '').toLowerCase()];\n }\n\n return [];\n\n function getTitle() {\n const [field] = record.get(/^245$/u);\n\n if (field) {\n return field.subfields\n .filter(({code}) => ['a', 'b'].includes(code))\n .map(({value}) => value)\n .join('');\n }\n }\n },\n compare: (a, b) => {\n const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features/bib/title-version-original');\n const distance = leven(a[0], b[0]);\n\n if (distance === 0) {\n return 0.5;\n }\n\n const maxLength = getMaxLength();\n const percentage = distance / maxLength * 100;\n\n debug(`'${a}' vs '${b}': Max length = ${maxLength}, distance = ${distance}, percentage = ${percentage}`);\n\n if (percentage <= threshold) {\n return 0.3;\n }\n\n return -0.5;\n\n function getMaxLength() {\n return a[0].length > b[0].length ? a[0].length : b[0].length;\n }\n\n }\n});\n"],
|
|
5
|
-
"mappings": "AACA,OAAO,uBAAuB;AAC9B,OAAO,gBAAgB;AACvB,MAAM,EAAC,qBAAqB,MAAK,IAAI;AAErC,eAAe,CAAC,EAAC,YAAY,GAAE,IAAI,CAAC,OAAO;AAAA,EACzC,MAAM;AAAA,EACN,SAAS,CAAC,EAAC,OAAM,MAAM;AACrB,UAAM,QAAQ,SAAS;AAEvB,QAAI,OAAO;AACT,aAAO,CAAC,MAAM,QAAQ,6BAA6B,EAAE,EAAE,YAAY,CAAC;AAAA,IACtE;AAEA,WAAO,CAAC;AAER,aAAS,WAAW;AAClB,YAAM,CAAC,KAAK,IAAI,OAAO,IAAI,QAAQ;AAEnC,UAAI,OAAO;AACT,eAAO,MAAM,UACV,OAAO,CAAC,EAAC,KAAI,MAAM,CAAC,KAAK,GAAG,EAAE,SAAS,IAAI,CAAC,EAC5C,IAAI,CAAC,EAAC,MAAK,MAAM,KAAK,EACtB,KAAK,EAAE;AAAA,MACZ;AAAA,IACF;AAAA,EACF;AAAA,EACA,SAAS,CAAC,GAAG,MAAM;AACjB,UAAM,QAAQ,kBAAkB,uFAAuF;AACvH,UAAM,WAAW,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC;AAEjC,QAAI,aAAa,GAAG;AAClB,aAAO;AAAA,IACT;AAEA,UAAM,YAAY,aAAa;AAC/B,UAAM,aAAa,WAAW,YAAY;AAE1C,UAAM,IAAI,CAAC,SAAS,CAAC,mBAAmB,SAAS,gBAAgB,QAAQ,kBAAkB,UAAU,EAAE;AAEvG,QAAI,cAAc,WAAW;AAC3B,aAAO;AAAA,IACT;AAEA,WAAO;AAEP,aAAS,eAAe;AACtB,aAAO,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE;AAAA,IACxD;AAAA,EAEF;AACF;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|