@natlibfi/melinda-record-matching 5.0.1-alpha.1 → 5.0.2-alpha.1
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/dist/candidate-search/index.js +6 -2
- package/dist/candidate-search/index.js.map +2 -2
- package/dist/candidate-search/query-list/bib.js +10 -2
- package/dist/candidate-search/query-list/bib.js.map +3 -3
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +2 -2
- package/dist/index.js +2 -2
- package/dist/index.js.map +2 -2
- package/dist/match-detection/features/bib/authors.js +2 -2
- package/dist/match-detection/features/bib/authors.js.map +2 -2
- package/dist/match-detection/features/bib/bibliographic-level.js +12 -1
- package/dist/match-detection/features/bib/bibliographic-level.js.map +2 -2
- package/dist/match-detection/features/bib/host-component.js +1 -1
- package/dist/match-detection/features/bib/host-component.js.map +2 -2
- package/dist/match-detection/features/bib/isbn.js +122 -15
- package/dist/match-detection/features/bib/isbn.js.map +2 -2
- package/dist/match-detection/features/bib/issn.js +62 -5
- package/dist/match-detection/features/bib/issn.js.map +2 -2
- package/dist/match-detection/features/bib/language.js +176 -59
- package/dist/match-detection/features/bib/language.js.map +3 -3
- package/dist/match-detection/features/bib/title-version-original.js +2 -2
- package/dist/match-detection/features/bib/title-version-original.js.map +2 -2
- package/dist/match-detection/features/bib/title.js +2 -2
- package/dist/match-detection/features/bib/title.js.map +2 -2
- package/dist/match-detection/index.js +5 -5
- package/dist/match-detection/index.js.map +2 -2
- package/package.json +9 -4
- package/src/candidate-search/index.js +7 -2
- package/src/candidate-search/query-list/bib.js +22 -15
- package/src/cli.js +1 -1
- package/src/index.js +3 -3
- package/src/match-detection/features/bib/authors.js +2 -2
- package/src/match-detection/features/bib/bibliographic-level.js +13 -1
- package/src/match-detection/features/bib/host-component.js +1 -1
- package/src/match-detection/features/bib/isbn.js +162 -14
- package/src/match-detection/features/bib/issn.js +83 -1
- package/src/match-detection/features/bib/language.js +261 -73
- package/src/match-detection/features/bib/title-version-original.js +2 -2
- package/src/match-detection/features/bib/title.js +5 -4
- package/src/match-detection/index.js +5 -5
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import createDebugLogger from "debug";
|
|
2
2
|
import naturalPkg from "natural";
|
|
3
3
|
const { LevenshteinDistance: leven } = naturalPkg;
|
|
4
|
-
export default ({
|
|
4
|
+
export default ({ threshold = 10 } = {}) => ({
|
|
5
5
|
name: "titleVersionOriginal",
|
|
6
6
|
extract: ({ record }) => {
|
|
7
7
|
const title = getTitle();
|
|
@@ -25,7 +25,7 @@ export default ({ treshold = 10 } = {}) => ({
|
|
|
25
25
|
const maxLength = getMaxLength();
|
|
26
26
|
const percentage = distance / maxLength * 100;
|
|
27
27
|
debug(`'${a}' vs '${b}': Max length = ${maxLength}, distance = ${distance}, percentage = ${percentage}`);
|
|
28
|
-
if (percentage <=
|
|
28
|
+
if (percentage <= threshold) {
|
|
29
29
|
return 0.3;
|
|
30
30
|
}
|
|
31
31
|
return -0.5;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
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 ({
|
|
5
|
-
"mappings": "AACA,OAAO,uBAAuB;AAC9B,OAAO,gBAAgB;AACvB,MAAM,EAAC,qBAAqB,MAAK,IAAI;AAErC,eAAe,CAAC,EAAC,
|
|
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
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -4,7 +4,7 @@ const { LevenshteinDistance: leven } = naturalPkg;
|
|
|
4
4
|
import { testStringOrNumber } from "../../../matching-utils.js";
|
|
5
5
|
const debug = createDebugLogger("@natlibfi/melinda-record-matching:match-detection:features:title");
|
|
6
6
|
const debugData = debug.extend("data");
|
|
7
|
-
export default ({
|
|
7
|
+
export default ({ threshold = 10 } = {}) => ({
|
|
8
8
|
name: "Title",
|
|
9
9
|
extract: ({ record, recordExternal }) => {
|
|
10
10
|
const label = recordExternal && recordExternal.label ? recordExternal.label : "record";
|
|
@@ -34,7 +34,7 @@ export default ({ treshold = 10 } = {}) => ({
|
|
|
34
34
|
const maxLength = getMaxLength();
|
|
35
35
|
const percentage = distance / maxLength * 100;
|
|
36
36
|
debug2(`'${a}' vs '${b}': Max length = ${maxLength}, distance = ${distance}, percentage = ${percentage}`);
|
|
37
|
-
if (percentage <=
|
|
37
|
+
if (percentage <= threshold) {
|
|
38
38
|
return 0.3;
|
|
39
39
|
}
|
|
40
40
|
return -0.5;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/match-detection/features/bib/title.js"],
|
|
4
|
-
"sourcesContent": ["import createDebugLogger from 'debug';\nimport naturalPkg from 'natural';\nconst {LevenshteinDistance: leven} = naturalPkg;\nimport {testStringOrNumber} from '../../../matching-utils.js';\n\nconst debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features:title');\nconst debugData = debug.extend('data');\n\n\nexport default ({
|
|
5
|
-
"mappings": "AAAA,OAAO,uBAAuB;AAC9B,OAAO,gBAAgB;AACvB,MAAM,EAAC,qBAAqB,MAAK,IAAI;AACrC,SAAQ,0BAAyB;AAEjC,MAAM,QAAQ,kBAAkB,kEAAkE;AAClG,MAAM,YAAY,MAAM,OAAO,MAAM;AAGrC,eAAe,CAAC,EAAC,
|
|
4
|
+
"sourcesContent": ["import createDebugLogger from 'debug';\nimport naturalPkg from 'natural';\nconst {LevenshteinDistance: leven} = naturalPkg;\nimport {testStringOrNumber} from '../../../matching-utils.js';\n\nconst debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features:title');\nconst debugData = debug.extend('data');\n\n\nexport default ({threshold = 10} = {}) => ({\n name: 'Title',\n extract: ({record, recordExternal}) => {\n const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';\n const title = getTitle();\n debug(`${label}: title: ${title}`);\n\n if (testStringOrNumber(title)) {\n const titleAsNormalizedString = String(title)\n // decompose unicode diacritics\n .normalize('NFD')\n // strip non-letters/numbers\n // - note: combined with decomposing unicode diacritics this normalizes both 'saa' and 's\u00E4\u00E4' as 'saa'\n // - we could precompose the Finnish letters back to avoid this\n // - see validator normalize-utf8-diacritics for details\n .replace(/[^\\p{Letter}\\p{Number}]/gu, '')\n .toLowerCase();\n debug(`${label}: titleString: ${titleAsNormalizedString}`);\n return [titleAsNormalizedString];\n }\n\n return [];\n\n function getTitle() {\n const [field] = record.get(/^245$/u);\n debugData(`${label}: titleField: ${JSON.stringify(field)}`);\n\n if (field) {\n return field.subfields\n // get also $n:s and $p:s here\n .filter(({code}) => ['a', 'b', 'n', 'p'].includes(code))\n .map(({value}) => testStringOrNumber(value) ? String(value) : '')\n .join('');\n }\n return false;\n }\n },\n compare: (a, b) => {\n const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features/bib/title');\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": "AAAA,OAAO,uBAAuB;AAC9B,OAAO,gBAAgB;AACvB,MAAM,EAAC,qBAAqB,MAAK,IAAI;AACrC,SAAQ,0BAAyB;AAEjC,MAAM,QAAQ,kBAAkB,kEAAkE;AAClG,MAAM,YAAY,MAAM,OAAO,MAAM;AAGrC,eAAe,CAAC,EAAC,YAAY,GAAE,IAAI,CAAC,OAAO;AAAA,EACzC,MAAM;AAAA,EACN,SAAS,CAAC,EAAC,QAAQ,eAAc,MAAM;AACrC,UAAM,QAAQ,kBAAkB,eAAe,QAAQ,eAAe,QAAQ;AAC9E,UAAM,QAAQ,SAAS;AACvB,UAAM,GAAG,KAAK,YAAY,KAAK,EAAE;AAEjC,QAAI,mBAAmB,KAAK,GAAG;AAC7B,YAAM,0BAA0B,OAAO,KAAK,EAEzC,UAAU,KAAK,EAKf,QAAQ,6BAA6B,EAAE,EACvC,YAAY;AACf,YAAM,GAAG,KAAK,kBAAkB,uBAAuB,EAAE;AACzD,aAAO,CAAC,uBAAuB;AAAA,IACjC;AAEA,WAAO,CAAC;AAER,aAAS,WAAW;AAClB,YAAM,CAAC,KAAK,IAAI,OAAO,IAAI,QAAQ;AACnC,gBAAU,GAAG,KAAK,iBAAiB,KAAK,UAAU,KAAK,CAAC,EAAE;AAE1D,UAAI,OAAO;AACT,eAAO,MAAM,UAEV,OAAO,CAAC,EAAC,KAAI,MAAM,CAAC,KAAK,KAAK,KAAK,GAAG,EAAE,SAAS,IAAI,CAAC,EACtD,IAAI,CAAC,EAAC,MAAK,MAAM,mBAAmB,KAAK,IAAI,OAAO,KAAK,IAAI,EAAE,EAC/D,KAAK,EAAE;AAAA,MACZ;AACA,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EACA,SAAS,CAAC,GAAG,MAAM;AACjB,UAAMA,SAAQ,kBAAkB,sEAAsE;AACtG,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,IAAAA,OAAM,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
6
|
"names": ["debug"]
|
|
7
7
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import createDebugLogger from "debug";
|
|
2
2
|
import * as features from "./features/index.js";
|
|
3
3
|
export { features };
|
|
4
|
-
export default ({ strategy,
|
|
4
|
+
export default ({ strategy, threshold = 0.8999 }, returnStrategy = false, localSettings = {}) => ({ recordA, recordB, recordAExternal = {}, recordBExternal = {} }) => {
|
|
5
5
|
const minProbabilityQuantifier = 0.5;
|
|
6
6
|
const debug = createDebugLogger("@natlibfi/melinda-record-matching:match-detection");
|
|
7
7
|
const debugData = debug.extend("data");
|
|
8
|
-
debugData(`Strategy: ${JSON.stringify(strategy)},
|
|
8
|
+
debugData(`Strategy: ${JSON.stringify(strategy)}, Threshold: ${JSON.stringify(threshold)}, ReturnStrategy: ${JSON.stringify(returnStrategy)}`);
|
|
9
9
|
debugData(`Records: A: ${recordA}
|
|
10
10
|
B: ${recordB}`);
|
|
11
11
|
debug(`Externals: A: ${JSON.stringify(recordAExternal)}, B: ${JSON.stringify(recordBExternal)}`);
|
|
@@ -26,8 +26,8 @@ B: ${recordB}`);
|
|
|
26
26
|
const similarityVector = generateSimilarityVector(featurePairs);
|
|
27
27
|
if (similarityVector.some((v) => v >= minProbabilityQuantifier)) {
|
|
28
28
|
const probability = calculateProbability(similarityVector);
|
|
29
|
-
debug(`probability: ${probability} (
|
|
30
|
-
return returnResult({ match: probability >=
|
|
29
|
+
debug(`probability: ${probability} (Threshold: ${threshold})`);
|
|
30
|
+
return returnResult({ match: probability >= threshold, probability });
|
|
31
31
|
}
|
|
32
32
|
debugData(`No feature yielded minimum probability amount of points (${minProbabilityQuantifier})`);
|
|
33
33
|
return returnResult({ match: false, probability: 0 });
|
|
@@ -38,7 +38,7 @@ B: ${recordB}`);
|
|
|
38
38
|
function returnResult(result) {
|
|
39
39
|
if (returnStrategy) {
|
|
40
40
|
debug(`Returning detection strategy with the result`);
|
|
41
|
-
const resultWithStrategy = { match: result.match, probability: result.probability, strategy: formatStrategy(strategy),
|
|
41
|
+
const resultWithStrategy = { match: result.match, probability: result.probability, strategy: formatStrategy(strategy), threshold };
|
|
42
42
|
debugData(`${JSON.stringify(resultWithStrategy)}`);
|
|
43
43
|
return resultWithStrategy;
|
|
44
44
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/match-detection/index.js"],
|
|
4
|
-
"sourcesContent": ["\nimport createDebugLogger from 'debug';\nimport * as features from './features/index.js';\n\nexport {features};\n\nexport default ({strategy,
|
|
5
|
-
"mappings": "AACA,OAAO,uBAAuB;AAC9B,YAAY,cAAc;AAE1B,SAAQ;AAER,eAAe,CAAC,EAAC,UAAU,
|
|
4
|
+
"sourcesContent": ["\nimport createDebugLogger from 'debug';\nimport * as features from './features/index.js';\n\nexport {features};\n\nexport default ({strategy, threshold = 0.8999}, returnStrategy = false, localSettings = {}) => ({recordA, recordB, recordAExternal = {}, recordBExternal = {}}) => {\n const minProbabilityQuantifier = 0.5;\n\n const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection');\n const debugData = debug.extend('data');\n\n debugData(`Strategy: ${JSON.stringify(strategy)}, Threshold: ${JSON.stringify(threshold)}, ReturnStrategy: ${JSON.stringify(returnStrategy)}`);\n debugData(`Records: A: ${recordA}\\nB: ${recordB}`);\n debug(`Externals: A: ${JSON.stringify(recordAExternal)}, B: ${JSON.stringify(recordBExternal)}`);\n // We could add here labels for records if we didn't get external labels\n\n const featuresA = extractFeatures({record: recordA, recordExternal: recordAExternal, localSettings});\n\n debug(`We got an array of records: ${Array.isArray(recordB)}`);\n const recordsB = Array.isArray(recordB) ? recordB : [recordB];\n const recordsBExternal = Array.isArray(recordB) ? recordBExternal : [recordBExternal];\n\n const detectionResults = recordsB.map((record, index) => actualDetection({featuresA, recordAExternal, record, recordExternal: recordsBExternal[index], index}));\n\n // if we got array of records, we return an array of result\n // if we got a singular record, we return a singular result\n return Array.isArray(recordB) ? detectionResults : detectionResults[0];\n\n function actualDetection({featuresA, record, recordExternal, index, localSettings}) {\n const labelA = recordAExternal && recordAExternal.label ? recordAExternal.label : 'a';\n const labelB = recordExternal && recordExternal.label ? recordExternal.label : 'b';\n\n\n debug(`Actual detection for record ${index + 1} ${labelB}`);\n const featuresB = extractFeatures({record, recordExternal, localSettings});\n\n debugData(`Features (a: ${labelA}): ${JSON.stringify(featuresA)}`);\n debugData(`Features (b: ${labelB}): ${JSON.stringify(featuresB)}`);\n\n const featurePairs = generateFeaturePairs(featuresA, featuresB);\n const similarityVector = generateSimilarityVector(featurePairs);\n\n if (similarityVector.some(v => v >= minProbabilityQuantifier)) {\n const probability = calculateProbability(similarityVector);\n debug(`probability: ${probability} (Threshold: ${threshold})`);\n return returnResult({match: probability >= threshold, probability});\n }\n\n debugData(`No feature yielded minimum probability amount of points (${minProbabilityQuantifier})`);\n return returnResult({match: false, probability: 0.0});\n }\n\n function extractFeatures({record, recordExternal}) {\n return strategy.reduce((acc, {name, extract}) => acc.concat({name, value: extract({record, recordExternal})}), []);\n }\n\n function returnResult(result) {\n if (returnStrategy) {\n debug(`Returning detection strategy with the result`);\n const resultWithStrategy = {match: result.match, probability: result.probability, strategy: formatStrategy(strategy), threshold};\n debugData(`${JSON.stringify(resultWithStrategy)}`);\n return resultWithStrategy;\n }\n return result;\n }\n\n function formatStrategy(strategy) {\n const strategyNames = strategy.map(element => element.name);\n return strategyNames || [];\n }\n\n function calculateProbability(similarityVector) {\n const probability = similarityVector.reduce((acc, v) => acc + v, 0.0);\n return probability > 1.0 ? 1.0 : probability;\n }\n\n function generateSimilarityVector(featurePairs) {\n const compared = featurePairs.map(({name, a, b}) => {\n const {compare} = strategy.find(({name: featureName}) => name === featureName);\n const points = compare(a, b);\n return {name, points};\n });\n\n debugData(`Points: ${JSON.stringify(compared)}`);\n return compared.map(({points}) => points);\n }\n\n function generateFeaturePairs(featuresA, featuresB) {\n const pairs = generatePairs();\n const missingFeatures = findMissing();\n\n debug(`Not comparing the following features because one, or both records are missing features: ${JSON.stringify(missingFeatures)}`);\n return pairs;\n\n function generatePairs() {\n return featuresA\n .reduce((acc, {name, value}, index) => acc.concat({\n name,\n a: value,\n b: featuresB[index].value\n }), [])\n .filter(({a, b}) => {\n if (a.length === 0 || b.length === 0) {\n return false;\n }\n\n return true;\n });\n }\n\n function findMissing() {\n return featuresA\n .map(({name}) => name)\n .filter(v => pairs.some(({name}) => name === v) === false);\n }\n }\n\n};\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["featuresA", "localSettings", "strategy"]
|
|
7
7
|
}
|
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.2-alpha.1",
|
|
17
17
|
"type": "module",
|
|
18
18
|
"main": "./dist/index.js",
|
|
19
19
|
"bin": "./dist/cli.js",
|
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@natlibfi/marc-record": "^10.0.1",
|
|
41
41
|
"@natlibfi/marc-record-serializers": "^11.0.1",
|
|
42
|
+
"@natlibfi/marc-record-validators-melinda": "^12.0.6",
|
|
42
43
|
"@natlibfi/melinda-commons": "^14.0.2",
|
|
43
44
|
"@natlibfi/sru-client": "^7.0.1",
|
|
44
45
|
"debug": "^4.4.3",
|
|
45
|
-
"isbn3": "^2.0.
|
|
46
|
+
"isbn3": "^2.0.4",
|
|
46
47
|
"moment": "^2.30.1",
|
|
47
48
|
"natural": "^8.1.0",
|
|
48
49
|
"uuid": "^13.0.0",
|
|
@@ -50,10 +51,14 @@
|
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
53
|
"@natlibfi/fixugen": "^3.0.0",
|
|
53
|
-
"@natlibfi/fixugen-http-client": "^4.0.
|
|
54
|
+
"@natlibfi/fixugen-http-client": "^4.0.2",
|
|
54
55
|
"@natlibfi/fixura": "^4.0.0",
|
|
55
56
|
"cross-env": "^10.1.0",
|
|
56
57
|
"esbuild": "^0.27.2",
|
|
57
58
|
"eslint": "^9.39.2"
|
|
58
|
-
}
|
|
59
|
+
},
|
|
60
|
+
"overrides": {
|
|
61
|
+
"nanoid": "^3.3.8"
|
|
62
|
+
},
|
|
63
|
+
"nvolkComment": "cld3-asm 4.0.0 uses emscripten-wasm-loader ^3.0.3 which uses problematic, non-secure nanoid version 2.X.X."
|
|
59
64
|
}
|
|
@@ -112,9 +112,14 @@ export default async ({record, searchSpec, url, maxCandidates, maxRecordsPerRequ
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
function getRecordId(record) {
|
|
115
|
-
const [
|
|
116
|
-
|
|
115
|
+
const [f001] = record.get(/^001$/u);
|
|
116
|
+
const [f003] = record.get(/^003$/u);
|
|
117
|
+
if (f001 && f003) {
|
|
118
|
+
return `${f003.value}-${f001.value}`;
|
|
119
|
+
}
|
|
120
|
+
return f001 ? f001.value : '';
|
|
117
121
|
}
|
|
122
|
+
|
|
118
123
|
};
|
|
119
124
|
|
|
120
125
|
export function retrieveRecords(client, query, resultSetOffset) {
|
|
@@ -152,14 +152,9 @@ export function bibHostComponents(record) {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
// SRU search dc.title with a search phrase starting with ^ maps currently in Melinda to
|
|
156
|
-
// (probably) to *headings* index TIT
|
|
155
|
+
// SRU search dc.title with a search phrase starting with ^ maps currently in Melinda to (probably) to *headings* index TIT
|
|
157
156
|
// - Aleph cannot currently handle headings searches starting with a boolean - in these cases use word search
|
|
158
157
|
|
|
159
|
-
// Headings index TIT drops articles etc. from the start of the title according to the filing indicator
|
|
160
|
-
// Currently filing indicator is not implemented - if the title starts with an article and the Melinda
|
|
161
|
-
// record is correctly catalogued using a filing indicator -> dc.title search won't match
|
|
162
|
-
|
|
163
158
|
export function bibTitle(record) {
|
|
164
159
|
// We get author/publisher only when formatted title is shorter than 5 chars
|
|
165
160
|
return bibTitleAuthorPublisher({record, onlyTitleLength: 5});
|
|
@@ -193,13 +188,7 @@ export function bibTitleAuthorPublisher({record, onlyTitleLength, addYear = fals
|
|
|
193
188
|
debug(`bibTitleAuthorPublisher, onlyTitleLength: ${onlyTitleLength}, addYear: ${addYear}, alternates: ${alternates}`);
|
|
194
189
|
const title = getTitle();
|
|
195
190
|
if (testStringOrNumber(title)) {
|
|
196
|
-
const formatted =
|
|
197
|
-
.replace(/[^\w\s\p{Alphabetic}]/gu, '')
|
|
198
|
-
// Clean up concurrent spaces from fe. subfield changes
|
|
199
|
-
.replace(/ +/gu, ' ')
|
|
200
|
-
.trim()
|
|
201
|
-
.slice(0, 30)
|
|
202
|
-
.trim();
|
|
191
|
+
const formatted = getFormatted(title);
|
|
203
192
|
|
|
204
193
|
// use word search for titles starting with a boolean
|
|
205
194
|
const useWordSearch = checkUseWordSearch(formatted);
|
|
@@ -216,6 +205,19 @@ export function bibTitleAuthorPublisher({record, onlyTitleLength, addYear = fals
|
|
|
216
205
|
const newAlternateQueries = alternates ? [...alternateQueries, query] : alternateQueries;
|
|
217
206
|
|
|
218
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();
|
|
218
|
+
|
|
219
|
+
return formatted;
|
|
220
|
+
}
|
|
219
221
|
}
|
|
220
222
|
|
|
221
223
|
return [];
|
|
@@ -273,6 +275,10 @@ export function bibTitleAuthorPublisher({record, onlyTitleLength, addYear = fals
|
|
|
273
275
|
.filter(value => value)
|
|
274
276
|
// In Melinda's index subfield separators are indexed as ' '
|
|
275
277
|
.join(' ');
|
|
278
|
+
|
|
279
|
+
if (/^[1-9]$/u.test(field.ind2)) { // Skip non-filing characters
|
|
280
|
+
return titleString.slice(parseInt(field.ind2));
|
|
281
|
+
}
|
|
276
282
|
return titleString;
|
|
277
283
|
}
|
|
278
284
|
return false;
|
|
@@ -316,7 +322,7 @@ export function bibAuthors(record) {
|
|
|
316
322
|
|
|
317
323
|
if (field) {
|
|
318
324
|
const authorString = field.subfields
|
|
319
|
-
.filter(({code}) => ['a'].includes(code))
|
|
325
|
+
.filter(({code}) => ['a'].includes(code)) // We might use different subfield code sets for X00, X10 and X11?
|
|
320
326
|
.map(({value}) => testStringOrNumber(value) ? String(value) : '')
|
|
321
327
|
.filter(value => value)
|
|
322
328
|
// In Melinda's index subfield separators are indexed as ' '
|
|
@@ -393,9 +399,10 @@ export function bibYear(record) {
|
|
|
393
399
|
}
|
|
394
400
|
|
|
395
401
|
export function checkUseWordSearch(formatted) {
|
|
402
|
+
const lowercased = formatted.toLowerCase();
|
|
396
403
|
// Note: add a space to startWords to catch just actual boolean words
|
|
397
404
|
const booleanStartWords = ['and ', 'or ', 'nor ', 'not '];
|
|
398
|
-
return booleanStartWords.some(word =>
|
|
405
|
+
return booleanStartWords.some(word => lowercased.startsWith(word));
|
|
399
406
|
}
|
|
400
407
|
|
|
401
408
|
export function bibStandardIdentifiers(record) {
|
package/src/cli.js
CHANGED
package/src/index.js
CHANGED
|
@@ -124,7 +124,7 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
124
124
|
// - candidate.record
|
|
125
125
|
// - probability
|
|
126
126
|
// - strategy (if returnStrategy option is true)
|
|
127
|
-
// -
|
|
127
|
+
// - threshold (if returnStrategy option is true)
|
|
128
128
|
// - matchQuery (if returnQuery option is true)
|
|
129
129
|
// failures: array of conversionFailures from candidate-search and matchErrors from matchDetection in error format {status, payload: {message, id}} if returnFailures is true
|
|
130
130
|
|
|
@@ -257,7 +257,7 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
257
257
|
|
|
258
258
|
if (detectionResult.match || returnNonMatches) {
|
|
259
259
|
debug(`${detectionResult.match ? `Record ${candidateId} (${newRecordCount}/${recordSetSize}) is a match!` : `Record ${candidateId} (${newRecordCount}/${recordSetSize}) is NOT a match!`}`);
|
|
260
|
-
debugData(`Strategy: ${JSON.stringify(detectionResult.strategy)},
|
|
260
|
+
debugData(`Strategy: ${JSON.stringify(detectionResult.strategy)}, Threshold: ${JSON.stringify(detectionResult.threshold)}`);
|
|
261
261
|
|
|
262
262
|
const matchResult = {
|
|
263
263
|
probability: detectionResult.probability,
|
|
@@ -268,7 +268,7 @@ export default ({detection: detectionOptions, search: searchOptions, maxMatches
|
|
|
268
268
|
};
|
|
269
269
|
const strategyResult = {
|
|
270
270
|
strategy: detectionResult.strategy,
|
|
271
|
-
|
|
271
|
+
threshold: detectionResult.threshold
|
|
272
272
|
};
|
|
273
273
|
const newMatch = returnStrategy ? {...matchResult, ...strategyResult} : {...matchResult};
|
|
274
274
|
|
|
@@ -6,7 +6,7 @@ import {testStringOrNumber} from '../../../matching-utils.js';
|
|
|
6
6
|
|
|
7
7
|
// We should extract also organisational authors (110/710)
|
|
8
8
|
|
|
9
|
-
export default ({
|
|
9
|
+
export default ({nameThreshold = 10} = {}) => ({
|
|
10
10
|
name: 'Authors',
|
|
11
11
|
extract: ({record}) => {
|
|
12
12
|
//const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
|
|
@@ -58,7 +58,7 @@ export default ({nameTreshold = 10} = {}) => ({
|
|
|
58
58
|
const maxLength = getMaxLength();
|
|
59
59
|
const percentage = distance / maxLength * 100;
|
|
60
60
|
|
|
61
|
-
return percentage <=
|
|
61
|
+
return percentage <= nameThreshold;
|
|
62
62
|
|
|
63
63
|
function getMaxLength() {
|
|
64
64
|
return a.length > b.length ? a.length : b.length;
|
|
@@ -2,5 +2,17 @@
|
|
|
2
2
|
export default () => ({
|
|
3
3
|
name: 'Bibliographic level',
|
|
4
4
|
extract: ({record}) => record.leader[7] ? [record.leader[7]] : [],
|
|
5
|
-
compare: (a, b) =>
|
|
5
|
+
compare: (a, b) => {
|
|
6
|
+
if (a[0] === b[0]) {
|
|
7
|
+
return 0.1;
|
|
8
|
+
}
|
|
9
|
+
// See MELINDA-7427: We have millions of LDR/07='b' where they should be LDR/07='a'. So don't penalize for these...
|
|
10
|
+
if (a[0] === 'a' && b[0] === 'b') {
|
|
11
|
+
return 0.0;
|
|
12
|
+
}
|
|
13
|
+
if (a[0] === 'b' && b[0] === 'a') {
|
|
14
|
+
return 0.0;
|
|
15
|
+
}
|
|
16
|
+
return -0.2;
|
|
17
|
+
}
|
|
6
18
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
export default () => ({
|
|
3
3
|
name: 'Host/Component record',
|
|
4
|
-
extract: ({record}) => record.get(/^
|
|
4
|
+
extract: ({record}) => record.get(/^[79]73$/u).length > 0 ? ['component'] : ['host'],
|
|
5
5
|
compare: (a, b) => a[0] === b[0] ? 0.0 : -1.0
|
|
6
6
|
});
|
|
@@ -1,26 +1,174 @@
|
|
|
1
1
|
|
|
2
2
|
import createDebugLogger from 'debug';
|
|
3
3
|
import {parse as isbnParse} from 'isbn3';
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
import {uniqArray} from './issn.js';
|
|
6
|
+
import {isComponentRecord} from '@natlibfi/melinda-commons';
|
|
7
|
+
|
|
5
8
|
|
|
6
9
|
const debug = createDebugLogger(`@natlibfi/melinda-record-matching:match-detection:features:standard-identifiers:ISBN`);
|
|
7
10
|
const debugData = debug.extend('data');
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
const MAX_SCORE = 0.75;
|
|
13
|
+
|
|
14
|
+
export default () => ({
|
|
15
|
+
name: 'ISBN',
|
|
16
|
+
extract: ({record/*, recordExternal*/}) => {
|
|
17
|
+
//const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
|
|
18
|
+
|
|
19
|
+
const host = !isComponentRecord(record, false, []);
|
|
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));
|
|
25
|
+
},
|
|
26
|
+
// eslint-disable-next-line max-statements
|
|
27
|
+
compare: (aa, bb) => {
|
|
28
|
+
debugData(`Comparing ISBN sets ${JSON.stringify(aa)} and ${JSON.stringify(bb)}`);
|
|
29
|
+
if (aa.length === 0 || bb.length === 0) {
|
|
30
|
+
// No data for decision
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const [subfieldCodeForGoodValues, subfieldCodeForBadValues] = getSubfieldCodes(aa[0].tag);
|
|
35
|
+
|
|
36
|
+
const [goodValuesA, badValuesA] = getValues(aa);
|
|
37
|
+
if (goodValuesA.length) {
|
|
38
|
+
debug(`GOOD VALUES (A): ${goodValuesA.join(', ')}`);
|
|
39
|
+
}
|
|
40
|
+
if (badValuesA.length) {
|
|
41
|
+
debug(`BAD VALUES (A): ${badValuesA.join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const [goodValuesB, badValuesB] = getValues(bb);
|
|
45
|
+
if (goodValuesB.length) {
|
|
46
|
+
debug(`GOOD VALUES (B): ${goodValuesB.join(', ')}`);
|
|
47
|
+
}
|
|
48
|
+
if (badValuesB.length) {
|
|
49
|
+
debug(`BAD VALUES (B): ${badValuesB.join(', ')}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const [sharedGoodValues, goodValuesAOnly, goodValuesBOnly] = getUnionData(goodValuesA, goodValuesB);
|
|
53
|
+
|
|
54
|
+
//debug(`GOOD\tBOTH: ${sharedGoodValues.length}, A only: ${goodValuesAOnly.length}, B only: ${goodValuesBOnly.length}`);
|
|
55
|
+
|
|
56
|
+
const hitScore = scoreHit();
|
|
57
|
+
|
|
58
|
+
function scoreHit() {
|
|
59
|
+
if (sharedGoodValues.length > 0) {
|
|
60
|
+
return MAX_SCORE;
|
|
61
|
+
}
|
|
62
|
+
// One record consider ISBN good and the other record considered it's canceled:
|
|
63
|
+
if (goodValuesA.some(valA => badValuesB.includes(valA)) || goodValuesB.some(valB => badValuesA.includes(valB))) {
|
|
64
|
+
return MAX_SCORE;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Value is bad, but looks isbn-ish to a human eye (not validating the isbn again, note than invalid isbns in 020$a are considered bad):
|
|
68
|
+
// Could happen for two canceled ISBNs for example. I'll give this two thirds of the full score
|
|
69
|
+
if (badValuesA.some(valA => looksGood(valA) && badValuesB.includes(valA)) || badValuesB.some(valB => looksGood(valB) && badValuesA.includes(valB))) {
|
|
70
|
+
return MAX_SCORE * 2 / 3;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (sharedGoodValues.length > 0) {
|
|
77
|
+
// Third argument (aka 'N times') is >= 0
|
|
78
|
+
return scoreData(hitScore, 0.8, goodValuesAOnly.length + goodValuesBOnly.length);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (hitScore === MAX_SCORE) {
|
|
82
|
+
// -1 is needed to make the third argument >= 0 (otherwise min val would be 0)
|
|
83
|
+
return scoreData(hitScore, 0.8, goodValuesAOnly.length + goodValuesBOnly.length - 1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (hitScore > 0) { // Canceled/invalid ISBNs match
|
|
87
|
+
// Note that this is not (currently) penalized for non-matching canceled/invalid ISBNs. Maybe it should be.
|
|
88
|
+
return scoreData(hitScore, 0.8, goodValuesAOnly.length + goodValuesBOnly.length);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// No match:
|
|
92
|
+
|
|
93
|
+
if (goodValuesA.length === 0 || goodValuesB === 0) { // At least one record did not have any good ISBNs, so not penalizing here! (Invalid 020$as are counted a bad.)
|
|
94
|
+
return 0.0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return -0.75; // Has good ISBNs on both records, but they did not match
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
function getSubfieldCodes(tag) {
|
|
101
|
+
if (tag === '773') {
|
|
102
|
+
return ['z', undefined];
|
|
103
|
+
}
|
|
104
|
+
return ['a', 'z'];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function looksGood(val) {
|
|
108
|
+
// isbn10 can end in X:
|
|
109
|
+
if (/^([0-9]-?){9}[0-9X]$/u.test(val)) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
// isbn13 can not:
|
|
113
|
+
if (/^([0-9]-?){12}[0-9]$/u.test(val)) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getValues(fields) {
|
|
120
|
+
const goodValues = fields.flatMap(f => f.subfields.filter(sf => sf.code === subfieldCodeForGoodValues)).map(sf => validatorAndNormalizer(sf.value));
|
|
121
|
+
const trueGoodValues = goodValues.filter(val => val.valid).map(val => val.value);
|
|
122
|
+
const wannabeGoodValues = goodValues.filter(val => !val.valid).map(val => val.value);
|
|
123
|
+
if (!subfieldCodeForBadValues) { // 773
|
|
124
|
+
return [trueGoodValues, wannabeGoodValues];
|
|
125
|
+
}
|
|
126
|
+
const badValues = fields.flatMap(f => f.subfields.filter(sf => sf.code === subfieldCodeForBadValues)).map(sf => sf.value);
|
|
127
|
+
return [uniqArray(trueGoodValues), uniqArray([...wannabeGoodValues, ...badValues])];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function validatorAndNormalizer(string) {
|
|
131
|
+
const string2 = string.replace(/\. -$/u, ''); // Remove punctuation (773$z)
|
|
132
|
+
|
|
133
|
+
// Hack: Historically we 020$a "1234567890 sidottu" etc. Try the LHS alone:
|
|
134
|
+
const string3 = string2.replace(/ .*$/u, '');
|
|
135
|
+
if (string2 !== string3) {
|
|
136
|
+
const altResult = validatorAndNormalizer(string3);
|
|
137
|
+
if (altResult.valid) {
|
|
138
|
+
return altResult;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const isbnParseResult = isbnParse(string2, '') || '';
|
|
143
|
+
debugData(`isbnParseResult: ${JSON.stringify(isbnParseResult)}`);
|
|
144
|
+
if (!isbnParseResult.isValid) {
|
|
145
|
+
debug(`Not parseable ISBN '${string2}', just removing hyphens`);
|
|
146
|
+
return {valid: false, value: string2.replace(/-/ug, '')};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
debug(`Parseable ISBN '${string2}', normalizing to ISBN-13 '${isbnParseResult.isbn13}'`);
|
|
150
|
+
return {valid: true, value: isbnParseResult.isbn13};
|
|
151
|
+
|
|
11
152
|
|
|
12
|
-
function validatorAndNormalizer(string) {
|
|
13
|
-
const isbnParseResult = isbnParse(string);
|
|
14
|
-
debugData(`isbnParseResult: ${JSON.stringify(isbnParseResult)}`);
|
|
15
|
-
if (isbnParseResult === null) {
|
|
16
|
-
debug(`Not parseable ISBN, just removing hyphens`);
|
|
17
|
-
return {valid: false, value: string.replace(/-/ug, '')};
|
|
18
153
|
}
|
|
19
|
-
debug(`Parseable ISBN, normalizing to ISBN-13`);
|
|
20
|
-
return {valid: true, value: isbnParseResult.isbn13};
|
|
21
154
|
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// These are outside the default function as I'll probably want to export these later on
|
|
22
158
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
159
|
+
function getUnionData(set1, set2) {
|
|
160
|
+
const shared = set1.filter(val => set2.includes(val));
|
|
161
|
+
const onlyInSet1 = set1.filter(val => !shared.includes(val));
|
|
162
|
+
const onlyInSet2 = set2.filter(val => !shared.includes(val));
|
|
163
|
+
return [shared, onlyInSet1, onlyInSet2];
|
|
164
|
+
}
|
|
26
165
|
|
|
166
|
+
function scoreData(score, factor, n) {
|
|
167
|
+
return innerScoreData(score, n);
|
|
168
|
+
function innerScoreData(currScore, remaining) {
|
|
169
|
+
if (remaining > 0) {
|
|
170
|
+
return innerScoreData(currScore * factor, remaining-1);
|
|
171
|
+
}
|
|
172
|
+
return Math.round(currScore * 100)/100; // 0.600000000001 => 0.6
|
|
173
|
+
}
|
|
174
|
+
}
|