@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.
Files changed (40) hide show
  1. package/dist/candidate-search/index.js +6 -2
  2. package/dist/candidate-search/index.js.map +2 -2
  3. package/dist/candidate-search/query-list/bib.js +10 -2
  4. package/dist/candidate-search/query-list/bib.js.map +3 -3
  5. package/dist/cli.js +1 -1
  6. package/dist/cli.js.map +2 -2
  7. package/dist/index.js +2 -2
  8. package/dist/index.js.map +2 -2
  9. package/dist/match-detection/features/bib/authors.js +2 -2
  10. package/dist/match-detection/features/bib/authors.js.map +2 -2
  11. package/dist/match-detection/features/bib/bibliographic-level.js +12 -1
  12. package/dist/match-detection/features/bib/bibliographic-level.js.map +2 -2
  13. package/dist/match-detection/features/bib/host-component.js +1 -1
  14. package/dist/match-detection/features/bib/host-component.js.map +2 -2
  15. package/dist/match-detection/features/bib/isbn.js +122 -15
  16. package/dist/match-detection/features/bib/isbn.js.map +2 -2
  17. package/dist/match-detection/features/bib/issn.js +62 -5
  18. package/dist/match-detection/features/bib/issn.js.map +2 -2
  19. package/dist/match-detection/features/bib/language.js +176 -59
  20. package/dist/match-detection/features/bib/language.js.map +3 -3
  21. package/dist/match-detection/features/bib/title-version-original.js +2 -2
  22. package/dist/match-detection/features/bib/title-version-original.js.map +2 -2
  23. package/dist/match-detection/features/bib/title.js +2 -2
  24. package/dist/match-detection/features/bib/title.js.map +2 -2
  25. package/dist/match-detection/index.js +5 -5
  26. package/dist/match-detection/index.js.map +2 -2
  27. package/package.json +9 -4
  28. package/src/candidate-search/index.js +7 -2
  29. package/src/candidate-search/query-list/bib.js +22 -15
  30. package/src/cli.js +1 -1
  31. package/src/index.js +3 -3
  32. package/src/match-detection/features/bib/authors.js +2 -2
  33. package/src/match-detection/features/bib/bibliographic-level.js +13 -1
  34. package/src/match-detection/features/bib/host-component.js +1 -1
  35. package/src/match-detection/features/bib/isbn.js +162 -14
  36. package/src/match-detection/features/bib/issn.js +83 -1
  37. package/src/match-detection/features/bib/language.js +261 -73
  38. package/src/match-detection/features/bib/title-version-original.js +2 -2
  39. package/src/match-detection/features/bib/title.js +5 -4
  40. 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 ({ treshold = 10 } = {}) => ({
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 <= treshold) {
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 ({treshold = 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 <= treshold) {\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,WAAW,GAAE,IAAI,CAAC,OAAO;AAAA,EACxC,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,UAAU;AAC1B,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;",
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 ({ treshold = 10 } = {}) => ({
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 <= treshold) {
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 ({treshold = 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 diactics this normalizes both 'saa' and 's\u00E4\u00E4' as 'saa'\n // - we could precompose the finnish letters back to avoid this\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 <= treshold) {\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,WAAW,GAAE,IAAI,CAAC,OAAO;AAAA,EACxC,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,EAIf,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,UAAU;AAC1B,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;",
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, treshold = 0.9 }, returnStrategy = false, localSettings = {}) => ({ recordA, recordB, recordAExternal = {}, recordBExternal = {} }) => {
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)}, Treshold: ${JSON.stringify(treshold)}, ReturnStrategy: ${JSON.stringify(returnStrategy)}`);
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} (Treshold: ${treshold})`);
30
- return returnResult({ match: probability >= treshold, 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), treshold };
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, treshold = 0.9}, 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)}, Treshold: ${JSON.stringify(treshold)}, 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} (Treshold: ${treshold})`);\n return returnResult({match: probability >= treshold, 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), treshold};\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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",
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.1-alpha.1",
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.2",
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.1",
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 [field] = record.get(/^001$/u);
116
- return field ? field.value : '';
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 = String(title)
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 => formatted.toLowerCase().startsWith(word));
405
+ return booleanStartWords.some(word => lowercased.startsWith(word));
399
406
  }
400
407
 
401
408
  export function bibStandardIdentifiers(record) {
package/src/cli.js CHANGED
@@ -55,7 +55,7 @@ async function cli() {
55
55
  debug(JSON.stringify(args));
56
56
 
57
57
  const detection = {
58
- treshold: 0.9,
58
+ threshold: 0.8999,
59
59
  strategy: generateStrategy(searchType)
60
60
  };
61
61
 
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
- // - treshold (if returnStrategy option is true)
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)}, Treshold: ${JSON.stringify(detectionResult.treshold)}`);
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
- treshold: detectionResult.treshold
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 ({nameTreshold = 10} = {}) => ({
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 <= nameTreshold;
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) => a[0] === b[0] ? 0.1 : -0.2
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(/^773$/u).length > 0 ? ['component'] : ['host'],
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
- import createInterface from './standard-identifier-factory.js';
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
- export default () => {
10
- const IDENTIFIER_NAME = 'ISBN';
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
- const {extract, compare} = createInterface({identifier: IDENTIFIER_NAME, pattern: /^020$/u, subfieldCodes: ['a', 'z'], validIdentifierSubfieldCodes: ['a'], invalidIdentifierSubfieldCodes: ['z'], validatorAndNormalizer});
24
- return {extract, compare, name: IDENTIFIER_NAME};
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
+ }