@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
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"url": "https://github.com/NatLibFi/melinda-record-matching-js"
|
|
14
14
|
},
|
|
15
15
|
"license": "MIT",
|
|
16
|
-
"version": "5.0.
|
|
16
|
+
"version": "5.0.5",
|
|
17
17
|
"type": "module",
|
|
18
18
|
"main": "./dist/index.js",
|
|
19
19
|
"bin": "./dist/cli.js",
|
|
@@ -40,27 +40,23 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@natlibfi/marc-record": "^10.0.1",
|
|
43
|
-
"@natlibfi/marc-record-serializers": "^11.0.
|
|
44
|
-
"@natlibfi/marc-record-validators-melinda": "^12.0.
|
|
43
|
+
"@natlibfi/marc-record-serializers": "^11.0.2",
|
|
44
|
+
"@natlibfi/marc-record-validators-melinda": "^12.0.15",
|
|
45
45
|
"@natlibfi/melinda-commons": "^14.0.2",
|
|
46
46
|
"@natlibfi/sru-client": "^7.0.1",
|
|
47
47
|
"debug": "^4.4.3",
|
|
48
48
|
"isbn3": "^2.0.6",
|
|
49
49
|
"moment": "^2.30.1",
|
|
50
50
|
"natural": "^8.1.1",
|
|
51
|
-
"uuid": "^
|
|
51
|
+
"uuid": "^14.0.0",
|
|
52
52
|
"yargs": "^18.0.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@natlibfi/fixugen": "^3.0.1",
|
|
56
|
-
"@natlibfi/fixugen-http-client": "^4.0.
|
|
56
|
+
"@natlibfi/fixugen-http-client": "^4.0.3",
|
|
57
57
|
"@natlibfi/fixura": "^4.0.1",
|
|
58
58
|
"cross-env": "^10.1.0",
|
|
59
|
-
"esbuild": "^0.
|
|
60
|
-
"eslint": "^10.
|
|
61
|
-
}
|
|
62
|
-
"overrides": {
|
|
63
|
-
"nanoid": "^3.3.8"
|
|
64
|
-
},
|
|
65
|
-
"nvolkComment": "cld3-asm 4.0.0 uses emscripten-wasm-loader ^3.0.3 which uses problematic, non-secure nanoid version 2.X.X."
|
|
59
|
+
"esbuild": "^0.28.0",
|
|
60
|
+
"eslint": "^10.2.1"
|
|
61
|
+
}
|
|
66
62
|
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import createDebugLogger from 'debug';
|
|
2
2
|
import {toQueries} from '../candidate-search-utils.js';
|
|
3
3
|
import {getMelindaIdsF035, validateSidFieldSubfieldCounts, getSubfieldValues, testStringOrNumber} from '../../matching-utils.js';
|
|
4
|
+
import {extractHostMelindaIdsFromField, extractPublicationYearFrom773, getHostMelindaFields} from './component.js';
|
|
5
|
+
import {fieldToString} from '@natlibfi/marc-record-validators-melinda';
|
|
6
|
+
import {getTitle} from '../../match-detection/features/bib/title.js';
|
|
4
7
|
|
|
5
8
|
const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search:query');
|
|
6
9
|
|
|
10
|
+
const IS_OK_ALONE_THRESHOLD = 5;
|
|
11
|
+
|
|
7
12
|
export function bibSourceIds(record) {
|
|
8
13
|
|
|
9
14
|
/* Melinda's SRU-index melinda.sourceid includes source IDs from SID fields in Melinda records
|
|
@@ -126,31 +131,27 @@ export function bibHostComponents(record) {
|
|
|
126
131
|
const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search:query:bibHostComponents');
|
|
127
132
|
const debugData = debug.extend('data');
|
|
128
133
|
debug(`Creating queries for hostIds`);
|
|
134
|
+
const f773s = getHostMelindaFields(record);
|
|
135
|
+
if (f773s.length !== 1) { // This needs some thinking...
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
129
138
|
|
|
130
|
-
const id =
|
|
131
|
-
|
|
139
|
+
const [id] = extractHostMelindaIdsFromField(f773s[0]);
|
|
140
|
+
debug(`Found id: ${JSON.stringify(id)}`);
|
|
132
141
|
|
|
133
|
-
|
|
142
|
+
// NB! record.isCR() does not work if LDR/07=a, so we get year from 773$g!
|
|
143
|
+
const year = extractPublicationYearFrom773(f773s[0]);
|
|
144
|
+
debug(`${fieldToString(f773s[0])} => ${year ? year : 'N/A'}`);
|
|
145
|
+
if (year) {
|
|
146
|
+
return [`dc.date=${year} AND melinda.partsofhost=${id}`];
|
|
147
|
+
}
|
|
134
148
|
|
|
135
|
-
|
|
136
|
-
const [field] = record.get(/^773$/u);
|
|
149
|
+
return [`melinda.partsofhost=${id}`];
|
|
137
150
|
|
|
138
|
-
|
|
139
|
-
const {value} = field.subfields.find(({code}) => code === 'w') || {};
|
|
151
|
+
}
|
|
140
152
|
|
|
141
|
-
if (testStringOrNumber(value) && (/^\(FI-MELINDA\)/u).test(String(value))) {
|
|
142
|
-
return String(value).replace(/^\(FI-MELINDA\)/u, '');
|
|
143
|
-
}
|
|
144
153
|
|
|
145
|
-
if (testStringOrNumber(value) && (/^\(FIN01\)/u).test(String(value))) {
|
|
146
|
-
return String(value).replace(/^\(FIN01\)/u, '');
|
|
147
|
-
}
|
|
148
154
|
|
|
149
|
-
return false;
|
|
150
|
-
}
|
|
151
|
-
return false;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
155
|
|
|
155
156
|
// SRU search dc.title with a search phrase starting with ^ maps currently in Melinda to (probably) to *headings* index TIT
|
|
156
157
|
// - Aleph cannot currently handle headings searches starting with a boolean - in these cases use word search
|
|
@@ -184,43 +185,78 @@ export function bibTitleAuthorYearAlternates(record) {
|
|
|
184
185
|
return {queryList: Array.from(origQueryList).reverse(), queryListType: 'alternates'};
|
|
185
186
|
}
|
|
186
187
|
|
|
188
|
+
function getTitleForQuery(record) {
|
|
189
|
+
const title = getTitle(record, ['a']);
|
|
190
|
+
if (title) {
|
|
191
|
+
const nWords = title.split(' ');
|
|
192
|
+
// If lone $a is deemed too short, fetch $b as well:
|
|
193
|
+
// (NV: I've seen pairs with 245$a-only vs 245$a$b, so I'd like to use $a only if it is long enough)
|
|
194
|
+
|
|
195
|
+
if (nWords < 3 || title.length < 12) {
|
|
196
|
+
const [f245] = record.get('245');
|
|
197
|
+
// If punctuation is ' =' I think that f245$a is good enough despite shortness. Trying to balance between two bad situations...
|
|
198
|
+
// "Suo (= short title) siellä, vetelä (= missing $b name in another language) täällä..."
|
|
199
|
+
if (f245 && f245.subfields.find(sf => sf.code === 'a' && sf.value.match(/ =$/u)) && title.length >= IS_OK_ALONE_THRESHOLD) {
|
|
200
|
+
return title;
|
|
201
|
+
}
|
|
202
|
+
return getTitle(record, ['a', 'b']); // Try to get a longer title
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return title;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function dcTitle(record, onlyTitleLength, alternates = false) {
|
|
209
|
+
const title = getTitleForQuery(record);
|
|
210
|
+
if (!testStringOrNumber(title)) {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const formatted = getFormattedTitle(title);
|
|
215
|
+
|
|
216
|
+
// use word search for titles starting with a boolean
|
|
217
|
+
const useWordSearch = checkUseWordSearch(formatted);
|
|
218
|
+
// Prevent too many matches / SRU crashing by having a minimum length
|
|
219
|
+
// Note that currently this fails matching if there are no matches from previous matchers
|
|
220
|
+
if (formatted.length >= onlyTitleLength && !alternates) {
|
|
221
|
+
return [`dc.title="${useWordSearch ? '' : '^'}${formatted}*"`, formatted, true];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const queryIsOkAlone = !useWordSearch && formatted.length >= IS_OK_ALONE_THRESHOLD;
|
|
225
|
+
|
|
226
|
+
// use word search without ending * also in combination searches to avoid SRU-server crashes [MRA-189]
|
|
227
|
+
return [`dc.title="${useWordSearch || !queryIsOkAlone ? '' : '^'}${formatted}${queryIsOkAlone ? '*' : ''}"`, formatted, queryIsOkAlone];
|
|
228
|
+
|
|
229
|
+
function getFormattedTitle(title) {
|
|
230
|
+
const formatted = String(title)
|
|
231
|
+
.replace(/[\-+ !"(){}\[\]<>;:.?/@*%=^_`~]/gu, ' ')
|
|
232
|
+
.replace(/[^\w\s\p{Alphabetic}]/gu, '') // Apparently matches to/works with non-aplhabetic scripts such as Chinese as well
|
|
233
|
+
.replace(/ +/gu, ' ') // Clean up concurrent spaces from eg. subfield changes
|
|
234
|
+
.trim()
|
|
235
|
+
.replace(/^(.{30}\S*) .*$/, "$1")
|
|
236
|
+
.trim();
|
|
237
|
+
|
|
238
|
+
return formatted;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
187
242
|
export function bibTitleAuthorPublisher({record, onlyTitleLength, addYear = false, alternates = false, alternateQueries = []}) {
|
|
188
243
|
debug(`bibTitleAuthorPublisher, onlyTitleLength: ${onlyTitleLength}, addYear: ${addYear}, alternates: ${alternates}`);
|
|
189
|
-
const
|
|
190
|
-
if (
|
|
191
|
-
|
|
244
|
+
const [query, formatted, queryIsOkAlone] = dcTitle(record, onlyTitleLength, alternates);
|
|
245
|
+
if (query === undefined) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
192
248
|
|
|
193
|
-
|
|
194
|
-
const useWordSearch = checkUseWordSearch(formatted);
|
|
195
|
-
// Prevent too many matches / SRU crashing by having a minimum length
|
|
196
|
-
// Note that currently this fails matching if there are no matches from previous matchers
|
|
197
|
-
if (formatted.length >= onlyTitleLength && !alternates) {
|
|
198
|
-
return [`dc.title="${useWordSearch ? '' : '^'}${formatted}*"`];
|
|
199
|
-
}
|
|
200
|
-
const queryIsOkAlone = formatted.length >= 5;
|
|
201
|
-
|
|
202
|
-
// use word search without ending * also in combination searches to avoid SRU-server crashes [MRA-189]
|
|
203
|
-
const query = `dc.title="${useWordSearch || !queryIsOkAlone ? '' : '^'}${formatted}${queryIsOkAlone ? '*' : ''}"`;
|
|
204
|
-
debug(`query: ${query}`);
|
|
205
|
-
const newAlternateQueries = alternates ? [...alternateQueries, query] : alternateQueries;
|
|
206
|
-
|
|
207
|
-
return addAuthorsToSearch({query, queryIsOkAlone, addYear, alternates, alternateQueries: newAlternateQueries});
|
|
208
|
-
|
|
209
|
-
function getFormatted(title) {
|
|
210
|
-
const formatted = String(title)
|
|
211
|
-
.replace(/[\-+ !"(){}\[\]<>;:.?/@*%=^_`~]/gu, ' ')
|
|
212
|
-
.replace(/[^\w\s\p{Alphabetic}]/gu, '')
|
|
213
|
-
// Clean up concurrent spaces from fe. subfield changes
|
|
214
|
-
.replace(/ +/gu, ' ')
|
|
215
|
-
.trim()
|
|
216
|
-
.replace(/^(.{30}\S*) .*$/, "$1")
|
|
217
|
-
.trim();
|
|
249
|
+
debug(`query: ${query}`);
|
|
218
250
|
|
|
219
|
-
|
|
220
|
-
|
|
251
|
+
// Prevent too many matches / SRU crashing by having a minimum length
|
|
252
|
+
// Note that currently this fails matching if there are no matches from previous matchers
|
|
253
|
+
if (formatted.length >= onlyTitleLength && !alternates) {
|
|
254
|
+
return [query];
|
|
221
255
|
}
|
|
222
256
|
|
|
223
|
-
|
|
257
|
+
const newAlternateQueries = alternates ? [...alternateQueries, query] : alternateQueries;
|
|
258
|
+
|
|
259
|
+
return addAuthorsToSearch({query, queryIsOkAlone, addYear, alternates, alternateQueries: newAlternateQueries});
|
|
224
260
|
|
|
225
261
|
function addAuthorsToSearch({query, queryIsOkAlone = false, addYear = false, alternates = false, alternateQueries = []}) {
|
|
226
262
|
debug('addAuthorsToSearch');
|
|
@@ -254,8 +290,8 @@ export function bibTitleAuthorPublisher({record, onlyTitleLength, addYear = fals
|
|
|
254
290
|
function addYearToSearch({query, queryIsOkAlone = false, alternates = false, alternateQueries = []}) {
|
|
255
291
|
const [yearQuery] = bibYear(record);
|
|
256
292
|
if (yearQuery !== undefined) {
|
|
257
|
-
const
|
|
258
|
-
return alternates ?
|
|
293
|
+
const yearAndOtherQueries = `${yearQuery} AND ${query}`;
|
|
294
|
+
return alternates ? [...alternateQueries, yearAndOtherQueries] : [yearAndOtherQueries];
|
|
259
295
|
}
|
|
260
296
|
if (queryIsOkAlone) {
|
|
261
297
|
return alternates ? alternateQueries : [`${query}`];
|
|
@@ -263,26 +299,6 @@ export function bibTitleAuthorPublisher({record, onlyTitleLength, addYear = fals
|
|
|
263
299
|
return [];
|
|
264
300
|
}
|
|
265
301
|
|
|
266
|
-
function getTitle() {
|
|
267
|
-
const [field] = record.get(/^245$/u);
|
|
268
|
-
|
|
269
|
-
if (field) {
|
|
270
|
-
const titleString = field.subfields
|
|
271
|
-
//.filter(({code}) => ['a', 'b', 'n', 'p'].includes(code))
|
|
272
|
-
.filter(({code}) => ['a', 'b'].includes(code))
|
|
273
|
-
//.filter(({code}) => ['a'].includes(code))
|
|
274
|
-
.map(({value}) => testStringOrNumber(value) ? String(value) : '')
|
|
275
|
-
.filter(value => value)
|
|
276
|
-
// In Melinda's index subfield separators are indexed as ' '
|
|
277
|
-
.join(' ');
|
|
278
|
-
|
|
279
|
-
if (/^[1-9]$/u.test(field.ind2)) { // Skip non-filing characters
|
|
280
|
-
return titleString.slice(parseInt(field.ind2));
|
|
281
|
-
}
|
|
282
|
-
return titleString;
|
|
283
|
-
}
|
|
284
|
-
return false;
|
|
285
|
-
}
|
|
286
302
|
}
|
|
287
303
|
|
|
288
304
|
export function bibAuthors(record) {
|
|
@@ -349,7 +365,10 @@ export function bibPublishers(record) {
|
|
|
349
365
|
.slice(0, 30)
|
|
350
366
|
.trim();
|
|
351
367
|
|
|
352
|
-
debugData(`Publisher string: ${formatted}`);
|
|
368
|
+
debugData(`Publisher string: '${formatted}'`);
|
|
369
|
+
if (formatted.length === 0) {
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
353
372
|
// use non-wildcard word search from dc.publisher
|
|
354
373
|
return [`dc.publisher="${formatted}"`];
|
|
355
374
|
}
|
|
@@ -358,7 +377,7 @@ export function bibPublishers(record) {
|
|
|
358
377
|
|
|
359
378
|
function getPublisher(record) {
|
|
360
379
|
//debugData(record);
|
|
361
|
-
const [field] = record.get(/^
|
|
380
|
+
const [field] = record.get(/^26[04]$/u).filter(f => f.subfields.some(sf => sf.code === 'b')); // Filter removes copyright-only fields
|
|
362
381
|
//debugData(field);
|
|
363
382
|
|
|
364
383
|
if (field) {
|
|
@@ -367,7 +386,8 @@ export function bibPublishers(record) {
|
|
|
367
386
|
.map(({value}) => testStringOrNumber(value) ? String(value) : '')
|
|
368
387
|
.filter(value => value)
|
|
369
388
|
// In Melinda's index subfield separators are indexed as ' '
|
|
370
|
-
.join(' ')
|
|
389
|
+
.join(' ')
|
|
390
|
+
.trim();
|
|
371
391
|
return publisherString;
|
|
372
392
|
}
|
|
373
393
|
return false;
|
|
@@ -387,14 +407,17 @@ export function bibYear(record) {
|
|
|
387
407
|
|
|
388
408
|
function getYear(record) {
|
|
389
409
|
const [f008] = record.get(/^008$/u);
|
|
390
|
-
if (f008
|
|
391
|
-
debug('f008 missing');
|
|
410
|
+
if (!f008 || !f008.value || f008.value.length < 11) {
|
|
411
|
+
debug('f008 missing/crappy');
|
|
392
412
|
return false;
|
|
393
413
|
}
|
|
394
414
|
|
|
395
415
|
debugData(`f008: ${JSON.stringify(f008)}`);
|
|
396
416
|
const {value} = f008;
|
|
397
|
-
|
|
417
|
+
if (value === '||||' || value === ' ') { // Meaningless values
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
return value.slice(7, 11);
|
|
398
421
|
}
|
|
399
422
|
}
|
|
400
423
|
|
|
@@ -1,40 +1,117 @@
|
|
|
1
1
|
import createDebugLogger from 'debug';
|
|
2
2
|
import {promisify} from 'util';
|
|
3
3
|
import {toQueries} from '../candidate-search-utils.js';
|
|
4
|
-
import {getSubfieldValues, testStringOrNumber, toMelindaIds} from '../../matching-utils.js';
|
|
4
|
+
import {getSubfieldValues, stringAfter, stringBefore, testStringOrNumber, toMelindaIds} from '../../matching-utils.js';
|
|
5
|
+
import {fieldToString, uniqArray} from '@natlibfi/marc-record-validators-melinda/dist/utils.js';
|
|
5
6
|
|
|
6
7
|
const setTimeoutPromise = promisify(setTimeout); // eslint-disable-line
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
debug(`Creating query for the Melinda Id host`);
|
|
9
|
+
const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search:query:component');
|
|
10
|
+
const debugData = debug.extend('data'); // eslint-disable-line
|
|
11
|
+
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
|
|
14
|
+
export function parse773g(field) {
|
|
15
|
+
const g = field?.subfields?.find(sf => sf.code === 'g'); // NB! returns the first $g
|
|
16
|
+
|
|
17
|
+
if (!g) {
|
|
18
|
+
return {};
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
return [];
|
|
21
|
+
const value = normalizeValue(g.value);
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
return {
|
|
24
|
+
// NB! We are not currently interested in volume (vuosikerta)
|
|
25
|
+
year: gToYear(value) || null,
|
|
26
|
+
number: gToNumber(value) || null,
|
|
27
|
+
pages: gToPages(value) || null // Might also be eg. Raita 5. However, returns only the number part
|
|
28
|
+
}
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
function normalizeValue(value) {
|
|
31
|
+
return value.replace(/(?:\. -|\.)$/u, '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function gToNumber(value) {
|
|
35
|
+
// Allow up to 3-digit numbers
|
|
36
|
+
const extract1 = value.replace(/^.*: ([1-9][0-9]?[0-9]), (?:p\.|page|pp\.|s\.|Seite|sida|sidor|sivu).*$/ui, '$1');
|
|
37
|
+
if (extract1 !== value) {
|
|
38
|
+
return extract1;
|
|
28
39
|
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
function gToPages(value) {
|
|
44
|
+
if (value.match(/^[0-9]+(?:-[0-9]+)?(?:, [0-9]+(?:-[0-9]+)?)*$/u)) {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
const numberPartOnly = value.replace(/^.*(?:p\.|raidat|raita|s\.|Seite|sivut?|pages?) /ui, '');
|
|
48
|
+
if (numberPartOnly !== value) {
|
|
49
|
+
return gToPages(numberPartOnly);
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
function gToYear(value) {
|
|
56
|
+
debug(`EXTRACT YEAR FROM '${value}`);
|
|
57
|
+
// extract year from within parentheses: $g vsk (yyyy) AND $g (YYYY)
|
|
58
|
+
if (value.match(/^[1-9][0-9]?[0-9]? ?\((?:20[012][0-9]|19[0-9][0-9])\)/u) || value.match(/^\((?:20[012][0-9]|19[0-9][0-9])\)/u) ) {
|
|
59
|
+
return stringBefore(stringAfter(value, '('), ')');
|
|
60
|
+
}
|
|
61
|
+
// If volume is missing, the year often seems to come without them parentheses:
|
|
62
|
+
if (value.match(/^(?:20[012][0-9]|19[0-9][0-9]) :/u)) {
|
|
63
|
+
return stringBefore(value, ' ');
|
|
64
|
+
}
|
|
65
|
+
// Seen in SB $g (vsk)year joulukuu
|
|
66
|
+
if (value.match(/^\([1-9][0-9]?\) ?(?:20[012][0-9]|19[0-9][0-9])[^0-9]/)) {
|
|
67
|
+
return value.replace(/^\([0-9]+\) ?/u, '').replace(/[^0-9].*$/u, '');
|
|
68
|
+
}
|
|
69
|
+
// Some magazines use DD.MM.YYYY
|
|
70
|
+
if (value.match(/^[^0-9]*(?:[1-9]|[12][0-9]|30|31)\.(?:1[012]|[1-9])\. ?(?:20[012][0-9]|19[0-9][0-9])[^0-9]/u)) {
|
|
71
|
+
return value.replace(/^[^0-9]*[0-9]+\.[0-9]+\. ?/u, '').replace(/[^0-9].*$/u, '');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function extractPublicationYearFrom773(field) {
|
|
79
|
+
const data = parse773g(field);
|
|
80
|
+
debug(`Extracted year ${data.year} from f773$g ${fieldToString(field)}`);
|
|
81
|
+
return data.year;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isMelindaSubfieldW(subfield) {
|
|
85
|
+
if (subfield.code !== 'w') {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return (/^\((?:FI-MELINDA|FIN01)\)0[0-9]{8}$/ui).test(subfield.value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function removeMelindaPrefixFromValue(value) {
|
|
92
|
+
return value.replace(/^(?:\(FI-MELINDA\)|\(FIN01\))/, '');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getHostMelindaFields(record) {
|
|
96
|
+
return record.get(/^773$/u).filter(f => f.subfields.some(sf => isMelindaSubfieldW(sf)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function extractHostMelindaIdsFromField(field) {
|
|
100
|
+
return field.subfields.filter(sf => isMelindaSubfieldW(sf)).map(sf => removeMelindaPrefixFromValue(sf.value));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getHostMelindaIds(record) {
|
|
104
|
+
return uniqArray(getHostMelindaFields(record).map(f => extractHostMelindaIdsFromField(f)).flat());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function hostIdMelinda(record) { // old function with replaced code
|
|
108
|
+
debug(`Creating query for the Melinda Id host`);
|
|
109
|
+
const ids = getHostMelindaIds(record);
|
|
110
|
+
if (ids.length === 0) {
|
|
111
|
+
debug(`No valid Melinda Id host found`);
|
|
112
|
+
return [];
|
|
37
113
|
}
|
|
114
|
+
return toQueries(getHostMelindaIds(record), 'melinda.partsofhost');
|
|
38
115
|
}
|
|
39
116
|
|
|
40
117
|
export async function hostIdOtherSource(record, client) {
|
package/src/cli.js
CHANGED
|
@@ -76,7 +76,8 @@ async function cli() {
|
|
|
76
76
|
if (['IDS'].includes(searchType)) {
|
|
77
77
|
return [
|
|
78
78
|
matchDetection.features.bib.melindaId(),
|
|
79
|
-
matchDetection.features.bib.allSourceIds()
|
|
79
|
+
matchDetection.features.bib.allSourceIds(),
|
|
80
|
+
matchDetection.features.bib.f773() // Host data
|
|
80
81
|
];
|
|
81
82
|
}
|
|
82
83
|
|
|
@@ -101,7 +102,8 @@ async function cli() {
|
|
|
101
102
|
// normal publicationTime and a reprintPublication time
|
|
102
103
|
matchDetection.features.bib.publicationTimeAllowConsYearsMulti(),
|
|
103
104
|
matchDetection.features.bib.language(),
|
|
104
|
-
matchDetection.features.bib.bibliographicLevel()
|
|
105
|
+
matchDetection.features.bib.bibliographicLevel(),
|
|
106
|
+
matchDetection.features.bib.f773() // Host data
|
|
105
107
|
];
|
|
106
108
|
}
|
|
107
109
|
|
|
@@ -113,7 +115,8 @@ async function cli() {
|
|
|
113
115
|
matchDetection.features.bib.title(),
|
|
114
116
|
matchDetection.features.bib.language(),
|
|
115
117
|
matchDetection.features.bib.authors(),
|
|
116
|
-
matchDetection.features.bib.bibliographicLevel()
|
|
118
|
+
matchDetection.features.bib.bibliographicLevel(),
|
|
119
|
+
matchDetection.features.bib.f773() // Host data
|
|
117
120
|
];
|
|
118
121
|
}
|
|
119
122
|
|
|
@@ -128,7 +131,8 @@ async function cli() {
|
|
|
128
131
|
matchDetection.features.bib.recordType(),
|
|
129
132
|
matchDetection.features.bib.publicationTime(),
|
|
130
133
|
matchDetection.features.bib.language(),
|
|
131
|
-
matchDetection.features.bib.bibliographicLevel()
|
|
134
|
+
matchDetection.features.bib.bibliographicLevel(),
|
|
135
|
+
matchDetection.features.bib.f773() // Host data
|
|
132
136
|
];
|
|
133
137
|
}
|
|
134
138
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// NB! This checks identifiers ($w=ID, $x=ISSN, $z=ISBN and $o=others) to check whether the host is the same.
|
|
2
|
+
// BB! $g is not checked *yet*. It could very strongly indicate that the records are the same... (I doubt $q could be useful here)
|
|
3
|
+
// Rationale:
|
|
4
|
+
// 773$w indicates sameness. However, non-Melinda records probably refer to non-melinda hosts.
|
|
5
|
+
// Thus check other identifiers subfields ($x, $z and $o as well)
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
import createDebugLogger from 'debug';
|
|
10
|
+
|
|
11
|
+
import {isComponentRecord} from '@natlibfi/melinda-commons';
|
|
12
|
+
import {uniqArray} from './issn.js';
|
|
13
|
+
import {parse773g} from '../../../candidate-search/query-list/component.js';
|
|
14
|
+
|
|
15
|
+
const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features:issn');
|
|
16
|
+
const debugData = debug.extend('data');
|
|
17
|
+
|
|
18
|
+
const MAX_IDENTIFIER = 0.1; // This should be pretty low: it only says something about the host
|
|
19
|
+
const MAX_G = 0.5; // $g is about the comp itself, so score can be high here
|
|
20
|
+
|
|
21
|
+
export default () => ({
|
|
22
|
+
name: 'f773 ',
|
|
23
|
+
extract: ({record/*, recordExternal*/}) => {
|
|
24
|
+
//const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
|
|
25
|
+
|
|
26
|
+
if (!isComponentRecord(record, false, [])) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const f773s = record.get(/^773$/u); // Identifier extract handles multiple 773...
|
|
30
|
+
|
|
31
|
+
// I think it's ok, if, say, $o and $x values match, so I'm not keeping the subfield codes
|
|
32
|
+
const values = f773s.map(f => f.subfields)
|
|
33
|
+
.flat()
|
|
34
|
+
.filter(sf => ['w', 'o', 'x', 'z'].includes(sf.code))
|
|
35
|
+
.map(sf => normalizeValue(sf.value));
|
|
36
|
+
|
|
37
|
+
// $g: only on $g is supported
|
|
38
|
+
const gData = parse773g(f773s[0]);
|
|
39
|
+
|
|
40
|
+
return [uniqArray(values), gData];
|
|
41
|
+
|
|
42
|
+
function normalizeValue(value) {
|
|
43
|
+
return value.replace(/(?:\. -|\.)$/u, '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
compare: (aa, bb) => {
|
|
50
|
+
const [aIdentifiers, ag] = aa;
|
|
51
|
+
const [bIdentifiers, bg] = bb;
|
|
52
|
+
|
|
53
|
+
const identifierScore = scoreIdentifiers();
|
|
54
|
+
const gScore = scoreG();
|
|
55
|
+
|
|
56
|
+
if (identifierScore === MAX_IDENTIFIER && gScore === MAX_G) { // Pretty impressive hit, even if title matches not
|
|
57
|
+
return 1.0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return identifierScore + gScore;
|
|
61
|
+
|
|
62
|
+
function scoreG() {
|
|
63
|
+
// NB! $g contents are very noise, so wrong values may be extracted. Thus do not overpenalize.
|
|
64
|
+
|
|
65
|
+
// All exist match: things must be pretty good:
|
|
66
|
+
if (ag.number && ag.number === bg.number && ag.pages && ag.pages === bg.pages && ag.year && ag.year === bg.year) {
|
|
67
|
+
return MAX_G;
|
|
68
|
+
}
|
|
69
|
+
// Not comparing volume. It correlates with year.
|
|
70
|
+
return scoreYear() + scoreNumber() + scorePages();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function scoreNumber() {
|
|
74
|
+
if (!ag.number || !bg.number) {
|
|
75
|
+
return 0.0;
|
|
76
|
+
}
|
|
77
|
+
if (ag.number === bg.number) {
|
|
78
|
+
return 0.05;
|
|
79
|
+
}
|
|
80
|
+
return -0.02;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function scorePages() {
|
|
84
|
+
if (!ag.pages || !bg.pages) {
|
|
85
|
+
return 0.0;
|
|
86
|
+
}
|
|
87
|
+
if (ag.pages === bg.pages) { // If pages match, things must be pretty good
|
|
88
|
+
return 0.1;
|
|
89
|
+
}
|
|
90
|
+
return -0.05;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function scoreYear() { // publication-time.js also uses this, so don't score heavily here
|
|
94
|
+
if (!ag.year || !bg.year) {
|
|
95
|
+
return 0.0;
|
|
96
|
+
}
|
|
97
|
+
if (ag.year === bg.year) {
|
|
98
|
+
return 0.02;
|
|
99
|
+
}
|
|
100
|
+
return -0.02;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function scoreIdentifiers() {
|
|
104
|
+
debugData(`Comparing ISSN sets ${JSON.stringify(aIdentifiers)} and ${JSON.stringify(bIdentifiers)}`);
|
|
105
|
+
if (aIdentifiers.length === 0 || bIdentifiers.length === 0) {
|
|
106
|
+
// No data for decision
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
const firstSharedIdentifier = aIdentifiers.find(val => bIdentifiers.includes(val));
|
|
110
|
+
if (firstSharedIdentifier) {
|
|
111
|
+
debug(`\t Shared identifier found: '${firstSharedIdentifier}'`);
|
|
112
|
+
return MAX_IDENTIFIER;
|
|
113
|
+
}
|
|
114
|
+
return -0.5;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
@@ -4,7 +4,6 @@ export {default as isbn} from './isbn.js';
|
|
|
4
4
|
export {default as issn} from './issn.js';
|
|
5
5
|
export {default as otherStandardIdentifier} from './other-standard-identifier.js';
|
|
6
6
|
export {default as title} from './title.js';
|
|
7
|
-
export {default as titleVersionOriginal} from './title-version-original.js';
|
|
8
7
|
export {default as authors} from './authors.js';
|
|
9
8
|
export {default as recordType} from './record-type.js';
|
|
10
9
|
export {default as publicationTime} from './publication-time.js';
|
|
@@ -15,3 +14,4 @@ export {default as bibliographicLevel} from './bibliographic-level.js';
|
|
|
15
14
|
export {default as melindaId} from './melinda-id.js';
|
|
16
15
|
export {default as allSourceIds} from './all-source-ids.js';
|
|
17
16
|
export {default as mediaType} from './media-type.js';
|
|
17
|
+
export {default as f773} from './f773.js';
|
|
@@ -42,7 +42,7 @@ describe('match-detection/features/bib/', () => {
|
|
|
42
42
|
const {featuresA, featuresB, expectedPoints} = expectations;
|
|
43
43
|
const {compare} = features[feature](options);
|
|
44
44
|
|
|
45
|
-
assert.equal(compare(featuresA, featuresB), expectedPoints);
|
|
45
|
+
assert.equal(Math.round(compare(featuresA, featuresB) *100)/100, expectedPoints);
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -3,8 +3,6 @@ import createDebugLogger from 'debug';
|
|
|
3
3
|
import {parse as isbnParse} from 'isbn3';
|
|
4
4
|
|
|
5
5
|
import {uniqArray} from './issn.js';
|
|
6
|
-
import {isComponentRecord} from '@natlibfi/melinda-commons';
|
|
7
|
-
|
|
8
6
|
|
|
9
7
|
const debug = createDebugLogger(`@natlibfi/melinda-record-matching:match-detection:features:standard-identifiers:ISBN`);
|
|
10
8
|
const debugData = debug.extend('data');
|
|
@@ -16,12 +14,7 @@ export default () => ({
|
|
|
16
14
|
extract: ({record/*, recordExternal*/}) => {
|
|
17
15
|
//const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (host) {
|
|
22
|
-
return record.get('020').filter(f => f.subfields?.some(sf => ['a', 'z'].includes(sf.code) && sf.value));
|
|
23
|
-
}
|
|
24
|
-
return record.get('773').filter(f => f.subfields?.some(sf => ['z'].includes(sf.code) && sf.value));
|
|
17
|
+
return record.get('020').filter(f => f.subfields?.some(sf => ['a', 'z'].includes(sf.code) && sf.value));
|
|
25
18
|
},
|
|
26
19
|
// eslint-disable-next-line max-statements
|
|
27
20
|
compare: (aa, bb) => {
|
|
@@ -31,7 +24,8 @@ export default () => ({
|
|
|
31
24
|
return 0;
|
|
32
25
|
}
|
|
33
26
|
|
|
34
|
-
const
|
|
27
|
+
const subfieldCodeForGoodValues = 'a';
|
|
28
|
+
const subfieldCodeForBadValues = 'z';
|
|
35
29
|
|
|
36
30
|
const [aValidValuesA, aInvalidValuesA, zValidValuesA, zInvalidValuesA] = getValuesWrapper(aa, 'AA'); // initial 'a' and 'z' refer to 020 subfields codes
|
|
37
31
|
const [aValidValuesB, aInvalidValuesB, zValidValuesB, zInvalidValuesB] = getValuesWrapper(bb, 'BB');
|
|
@@ -124,14 +118,6 @@ export default () => ({
|
|
|
124
118
|
// We have values but they disagree:
|
|
125
119
|
return -0.75; // Has good ISBNs on both records, but they did not match
|
|
126
120
|
|
|
127
|
-
|
|
128
|
-
function getSubfieldCodes(tag) {
|
|
129
|
-
if (tag === '773') {
|
|
130
|
-
return ['z', undefined];
|
|
131
|
-
}
|
|
132
|
-
return ['a', 'z'];
|
|
133
|
-
}
|
|
134
|
-
|
|
135
121
|
function getValues(fields) {
|
|
136
122
|
// Valid values are normalized to their isbn-13 form. Invalid values get their '-'s removed.
|
|
137
123
|
const goodValues = fields.flatMap(f => f.subfields.filter(sf => sf.code === subfieldCodeForGoodValues)).map(sf => validatorAndNormalizer(sf.value));
|