├── tests ├── durham.test.js ├── webpac.test.js ├── luci.test.js ├── iguana.test.js ├── aspen.test.js ├── koha.test.js ├── prism3.test.js ├── index.js ├── enterprise.test.js ├── arena.test.js ├── spydus.test.js └── tests.json ├── .vscode └── settings.json ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── connectors ├── openlibrary.js ├── librarything.js ├── webpac.js ├── common.js ├── koha.v22.js ├── spydus.js ├── koha.v20.js ├── luci.js ├── koha.v23.js ├── prism3.js ├── durham.js ├── iguana.js ├── enterprise.js ├── aspen.js ├── arena.v7.js └── arena.v8.js ├── package.json ├── LICENSE ├── .gitignore ├── SectigoRSADomainValidationSecureServerCA.cer ├── README.md └── index.js /tests/durham.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const idx = require('.') 4 | 5 | const t = 300000 6 | 7 | test('E06000047', async () => await idx.runTest('Durham'), t) 8 | -------------------------------------------------------------------------------- /tests/webpac.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const idx = require('.') 4 | 5 | const t = 300000 6 | 7 | test('S12000028', async () => await idx.runTest('South Ayrshire'), t) 8 | -------------------------------------------------------------------------------- /tests/luci.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const idx = require('.') 4 | 5 | const t = 300000 6 | 7 | test('E06000032', async () => await idx.runTest('Luton'), t) 8 | test('E09000029', async () => await idx.runTest('Sutton'), t) 9 | test('E08000024', async () => await idx.runTest('Sunderland'), t) 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true, 8 | "**/Thumbs.db": true, 9 | "**/node_modules": true 10 | }, 11 | "jest.autoRun": "off" 12 | } -------------------------------------------------------------------------------- /tests/iguana.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const idx = require('.') 4 | 5 | const t = 300000 6 | 7 | test('S12000006', async () => await idx.runTest('Dumfries and Galloway'), t) 8 | test('S12000038', async () => await idx.runTest('Renfrewshire'), t) 9 | test('E06000021', async () => await idx.runTest('Stoke on Trent'), t) 10 | -------------------------------------------------------------------------------- /tests/aspen.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const idx = require('.') 4 | 5 | const t = 300000 6 | 7 | test('S12000045', async () => await idx.runTest('East Dunbartonshire'), t) 8 | test('E09000020', async () => await idx.runTest('Kensington and Chelsea'), t) 9 | test('E06000002', async () => await idx.runTest('Middlesbrough'), t) 10 | test('S12000013', async () => await idx.runTest('Western Isles'), t) 11 | test('E09000033', async () => await idx.runTest('Westminster'), t) 12 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to NPM 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | id-token: write 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: '20.x' 16 | registry-url: 'https://registry.npmjs.org' 17 | - run: npm ci 18 | - run: npm publish --provenance --access public 19 | env: 20 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 21 | -------------------------------------------------------------------------------- /connectors/openlibrary.js: -------------------------------------------------------------------------------- 1 | const request = require('superagent') 2 | 3 | const URL = 'https://openlibrary.org/search.json?q=' 4 | 5 | exports.search = async query => { 6 | const agent = request.agent() 7 | const responseData = { books: [] } 8 | 9 | try { 10 | const searchRequest = await agent.get(URL + query).timeout(1000) 11 | searchRequest.body.docs.forEach((b, a) => { 12 | responseData.books.push({ 13 | title: b.title, 14 | author: b.author_name, 15 | isbn: b.isbn 16 | }) 17 | }) 18 | } catch (e) {} 19 | 20 | return responseData 21 | } 22 | -------------------------------------------------------------------------------- /connectors/librarything.js: -------------------------------------------------------------------------------- 1 | const request = require('superagent') 2 | const xml2js = require('xml2js') 3 | 4 | const URL = 'https://www.librarything.com/api/thingISBN/' 5 | 6 | /** 7 | * Gets a set of ISBNs relating to a single ISBN from the library thing thingISBN service 8 | * @param {string} isbn 9 | */ 10 | exports.thingISBN = async isbn => { 11 | const agent = request.agent() 12 | const responseISBNs = { isbns: [] } 13 | 14 | let isbns = null 15 | try { 16 | const isbnRequest = await agent.get(URL + isbn).timeout(1000) 17 | const isbnJs = await xml2js.parseStringPromise(isbnRequest.text) 18 | isbns = isbnJs.idlist.isbn 19 | } catch (e) {} 20 | 21 | if (isbns) isbns.forEach(item => responseISBNs.isbns.push(item)) 22 | 23 | return responseISBNs 24 | } 25 | -------------------------------------------------------------------------------- /tests/koha.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const idx = require('.') 4 | 5 | const t = 300000 6 | 7 | test('S12000005', async () => await idx.runTest('Clackmannanshire'), t) 8 | test('E06000049', async () => await idx.runTest('Cheshire East'), t) 9 | test('E06000050', async () => await idx.runTest('Cheshire West and Chester'), t) 10 | test('E06000063', async () => await idx.runTest('Cumberland'), t) 11 | test('E06000006', async () => await idx.runTest('Halton'), t) 12 | test('E08000011', async () => await idx.runTest('Knowsley'), t) 13 | test('E08000021', async () => await idx.runTest('Newcastle upon Tyne'), t) 14 | test('E08000014', async () => await idx.runTest('Sefton'), t) 15 | test('E08000013', async () => await idx.runTest('St Helens'), t) 16 | test('E06000007', async () => await idx.runTest('Warrington'), t) 17 | test('E06000064', async () => await idx.runTest('Westmorland and Furness'), t) 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "0 13 * * 5" # Every Friday at 13:00 UTC 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Install modules 12 | run: npm install 13 | - name: Install OpenVPN 14 | run: | 15 | curl ${{ secrets.OVPN_URL }} --output config.ovpn --silent 16 | sudo apt update 17 | sudo apt install -y openvpn openvpn-systemd-resolved 18 | - name: Connect to VPN 19 | uses: "kota65535/github-openvpn-connect-action@v2" 20 | with: 21 | config_file: config.ovpn 22 | username: ${{ secrets.OVPN_USERNAME }} 23 | password: ${{ secrets.OVPN_PASSWORD }} 24 | - name: Run tests 25 | run: npm run test-ci 26 | # Give time for the logs to be uploaded; we don't want the VPN 27 | # to die before they are! 28 | - name: Sleep for 20 seconds 29 | uses: whatnick/wait-action@master 30 | with: 31 | time: "20s" 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "catalogues-library", 3 | "version": "1.4.1", 4 | "description": "A JS library for searching library catalogues", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest --runInBand", 8 | "test-ci": "jest --silent", 9 | "test-file": "jest --no-color 2>output.txt" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/LibrariesHacked/catalogues-library.git" 14 | }, 15 | "author": "Libraries Hacked", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/LibrariesHacked/catalogues-library/issues" 19 | }, 20 | "homepage": "https://github.com/LibrariesHacked/catalogues-library#readme", 21 | "devDependencies": { 22 | "jest": "^29.7.0" 23 | }, 24 | "dependencies": { 25 | "@small-tech/syswide-cas": "^6.0.2", 26 | "@types/tough-cookie": "^4.0.5", 27 | "async": "^3.2.6", 28 | "cheerio": "^1.1.2", 29 | "cycletls": "^1.0.27", 30 | "superagent": "^10.2.3", 31 | "tough-cookie": "^5.1.2", 32 | "user-agents": "^1.1.669", 33 | "uuid": "^11.1.0", 34 | "xml2js": "^0.6.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Libraries Hacked 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/prism3.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const idx = require('.') 4 | 5 | const t = 300000 6 | 7 | test('E09000003', async () => await idx.runTest('Barnet'), t) 8 | test('E06000036', async () => await idx.runTest('Bracknell Forest'), t) 9 | test('E09000006', async () => await idx.runTest('Bromley'), t) 10 | test('E06000052', async () => await idx.runTest('Cornwall'), t) 11 | test('E08000027', async () => await idx.runTest('Dudley'), t) 12 | test('E08000037', async () => await idx.runTest('Gateshead'), t) 13 | test('E09000011', async () => await idx.runTest('Greenwich'), t) 14 | test('E06000019', async () => await idx.runTest('Herefordshire'), t) 15 | test('E09000019', async () => await idx.runTest('Islington'), t) 16 | test('Jersey', async () => await idx.runTest('Jersey'), t) 17 | test('E10000017', async () => await idx.runTest('Lancashire'), t) 18 | test('E10000019', async () => await idx.runTest('Lincolnshire'), t) 19 | test('E08000012 - Liverpool', async () => await idx.runTest('Liverpool'), t) 20 | test('E08000022', async () => await idx.runTest('North Tyneside'), t) 21 | test('E08000028', async () => await idx.runTest('Sandwell'), t) 22 | test('E08000023', async () => await idx.runTest('South Tyneside'), t) 23 | test('E08000030', async () => await idx.runTest('Walsall'), t) 24 | test('E08000015', async () => await idx.runTest('Wirral'), t) 25 | test('E08000031', async () => await idx.runTest('Wolverhampton'), t) 26 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env expect */ 2 | /* global expect */ 3 | 4 | const index = require('../index') 5 | const tests = require('./tests.json') 6 | 7 | const librariesIgnoreList = [ 8 | // Only has "All Locations" option 9 | 'Southampton', 10 | // An empty dropdown list 11 | 'North Yorkshire' 12 | ] 13 | 14 | exports.runTest = async service => { 15 | let results = null 16 | 17 | // Libraries 18 | if (!librariesIgnoreList.includes(service)) { 19 | results = await index.libraries(service) 20 | 21 | expect(results).not.toHaveLength(0) 22 | expect(results[0].libraries).not.toHaveLength(0) 23 | } 24 | 25 | // Availability 26 | 27 | /* 28 | The sequence of ISBNs is generally as follows: 29 | 30 | 1. Harry Potter and the Philosopher's Stone 31 | 2. Nineteen Eighty Four 32 | 3. Pride and Prejudice 33 | 4. Hamlet 34 | 5. Gangsta Granny 35 | 36 | Where a library doesn't have one of the above books, a similar book 37 | has been substituted: another J K Rowling, Jane Austin or David Walliams 38 | book, for example. 39 | 40 | The following libraries do not have ISBNs available for availability checks: 41 | 42 | - Bexley - some issue with cookies that I just can't seem to work around. 43 | */ 44 | 45 | const isbns = tests.find(x => x.Name === service).ISBNs 46 | 47 | if (!isbns || isbns.length === 0) { 48 | // Tests are either disabled or don't exist. 49 | return 50 | } 51 | 52 | results = [] 53 | 54 | for (const isbn of isbns) { 55 | results = await index.availability(isbn, service) 56 | 57 | // Just need one of the five ISBNs to return results (some books may go out of circulation). 58 | if (results && results.length > 0 && results[0].availability.length > 0) { 59 | break 60 | } 61 | } 62 | 63 | expect(results).not.toHaveLength(0) 64 | expect(results[0].availability) 65 | expect(results[0].availability).not.toHaveLength(0) 66 | 67 | for (const a of results[0].availability) { 68 | expect(a.available + a.unavailable).toBeGreaterThan(0) 69 | } 70 | expect(results[0].id).toBeTruthy() 71 | } 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | output.txt 107 | temp.test.js 108 | -------------------------------------------------------------------------------- /SectigoRSADomainValidationSecureServerCA.cer: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIGEzCCA/ugAwIBAgIQfVtRJrR2uhHbdBYLvFMNpzANBgkqhkiG9w0BAQwFADCB 3 | iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl 4 | cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV 5 | BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTgx 6 | MTAyMDAwMDAwWhcNMzAxMjMxMjM1OTU5WjCBjzELMAkGA1UEBhMCR0IxGzAZBgNV 7 | BAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEYMBYGA1UE 8 | ChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFJTQSBEb21haW4g 9 | VmFsaWRhdGlvbiBTZWN1cmUgU2VydmVyIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC 10 | AQ8AMIIBCgKCAQEA1nMz1tc8INAA0hdFuNY+B6I/x0HuMjDJsGz99J/LEpgPLT+N 11 | TQEMgg8Xf2Iu6bhIefsWg06t1zIlk7cHv7lQP6lMw0Aq6Tn/2YHKHxYyQdqAJrkj 12 | eocgHuP/IJo8lURvh3UGkEC0MpMWCRAIIz7S3YcPb11RFGoKacVPAXJpz9OTTG0E 13 | oKMbgn6xmrntxZ7FN3ifmgg0+1YuWMQJDgZkW7w33PGfKGioVrCSo1yfu4iYCBsk 14 | Haswha6vsC6eep3BwEIc4gLw6uBK0u+QDrTBQBbwb4VCSmT3pDCg/r8uoydajotY 15 | uK3DGReEY+1vVv2Dy2A0xHS+5p3b4eTlygxfFQIDAQABo4IBbjCCAWowHwYDVR0j 16 | BBgwFoAUU3m/WqorSs9UgOHYm8Cd8rIDZsswHQYDVR0OBBYEFI2MXsRUrYrhd+mb 17 | +ZsF4bgBjWHhMA4GA1UdDwEB/wQEAwIBhjASBgNVHRMBAf8ECDAGAQH/AgEAMB0G 18 | A1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAbBgNVHSAEFDASMAYGBFUdIAAw 19 | CAYGZ4EMAQIBMFAGA1UdHwRJMEcwRaBDoEGGP2h0dHA6Ly9jcmwudXNlcnRydXN0 20 | LmNvbS9VU0VSVHJ1c3RSU0FDZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDB2Bggr 21 | BgEFBQcBAQRqMGgwPwYIKwYBBQUHMAKGM2h0dHA6Ly9jcnQudXNlcnRydXN0LmNv 22 | bS9VU0VSVHJ1c3RSU0FBZGRUcnVzdENBLmNydDAlBggrBgEFBQcwAYYZaHR0cDov 23 | L29jc3AudXNlcnRydXN0LmNvbTANBgkqhkiG9w0BAQwFAAOCAgEAMr9hvQ5Iw0/H 24 | ukdN+Jx4GQHcEx2Ab/zDcLRSmjEzmldS+zGea6TvVKqJjUAXaPgREHzSyrHxVYbH 25 | 7rM2kYb2OVG/Rr8PoLq0935JxCo2F57kaDl6r5ROVm+yezu/Coa9zcV3HAO4OLGi 26 | H19+24rcRki2aArPsrW04jTkZ6k4Zgle0rj8nSg6F0AnwnJOKf0hPHzPE/uWLMUx 27 | RP0T7dWbqWlod3zu4f+k+TY4CFM5ooQ0nBnzvg6s1SQ36yOoeNDT5++SR2RiOSLv 28 | xvcRviKFxmZEJCaOEDKNyJOuB56DPi/Z+fVGjmO+wea03KbNIaiGCpXZLoUmGv38 29 | sbZXQm2V0TP2ORQGgkE49Y9Y3IBbpNV9lXj9p5v//cWoaasm56ekBYdbqbe4oyAL 30 | l6lFhd2zi+WJN44pDfwGF/Y4QA5C5BIG+3vzxhFoYt/jmPQT2BVPi7Fp2RBgvGQq 31 | 6jG35LWjOhSbJuMLe/0CjraZwTiXWTb2qHSihrZe68Zk6s+go/lunrotEbaGmAhY 32 | LcmsJWTyXnW0OMGuf1pGg+pRyrbxmRE1a6Vqe8YAsOf4vmSyrcjC8azjUeqkk+B5 33 | yOGBQMkKW+ESPMFgKuOXwIlCypTPRpgSabuY0MLTDXJLR27lk8QyKGOHQ+SwMj4K 34 | 00u/I5sUKUErmgQfky3xxzlIPK1aEn8= 35 | -----END CERTIFICATE----- 36 | -------------------------------------------------------------------------------- /tests/enterprise.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const idx = require('.') 4 | 5 | const t = 300000 6 | 7 | test('E06000014', async () => await idx.runTest('York City'), t) 8 | test('E09000002', async () => await idx.runTest('Barking and Dagenham'), t) 9 | test('E08000016', async () => await idx.runTest('Barnsley'), t) 10 | test('E08000032', async () => await idx.runTest('Bradford'), t) 11 | test('E09000005', async () => await idx.runTest('Brent'), t) 12 | test('E09000001', async () => await idx.runTest('City of London'), t) 13 | test('E09000008', async () => await idx.runTest('Croydon'), t) 14 | test('E09000009', async () => await idx.runTest('Ealing'), t) 15 | test('S12000008', async () => await idx.runTest('East Ayrshire'), t) 16 | test('E09000010', async () => await idx.runTest('Enfield'), t) 17 | test('E10000012', async () => await idx.runTest('Essex'), t) 18 | test('E09000012', async () => await idx.runTest('Hackney'), t) 19 | test('E09000013', async () => await idx.runTest('Hammersmith and Fulham'), t) 20 | test('E09000014', async () => await idx.runTest('Haringey'), t) 21 | test('E09000015', async () => await idx.runTest('Harrow'), t) 22 | test('E09000016', async () => await idx.runTest('Havering'), t) 23 | test('E09000017', async () => await idx.runTest('Hillingdon'), t) 24 | test('E09000018', async () => await idx.runTest('Hounslow'), t) 25 | test('E06000010', async () => await idx.runTest('Kingston upon Hull'), t) 26 | test('E09000021', async () => await idx.runTest('Kingston upon Thames'), t) 27 | test('E08000034', async () => await idx.runTest('Kirklees'), t) 28 | test('E08000035', async () => await idx.runTest('Leeds'), t) 29 | test('E09000023', async () => await idx.runTest('Lewisham'), t) 30 | test('E09000024', async () => await idx.runTest('Merton'), t) 31 | test('E09000025', async () => await idx.runTest('Newham'), t) 32 | test('E09000026', async () => await idx.runTest('Redbridge'), t) 33 | test('E08000018', async () => await idx.runTest('Rotherham'), t) 34 | test('E08000019', async () => await idx.runTest('Sheffield'), t) 35 | test('E10000028', async () => await idx.runTest('Staffordshire'), t) 36 | test('E10000030', async () => await idx.runTest('Surrey'), t) 37 | test('E06000034', async () => await idx.runTest('Thurrock'), t) 38 | test('E09000030', async () => await idx.runTest('Tower Hamlets'), t) 39 | test('E08000036', async () => await idx.runTest('Wakefield'), t) 40 | test('E09000031', async () => await idx.runTest('Waltham Forest'), t) 41 | test('S12000039', async () => await idx.runTest('West Dunbartonshire'), t) 42 | test('S12000040', async () => await idx.runTest('West Lothian'), t) 43 | test('E10000034', async () => await idx.runTest('Worcestershire'), t) 44 | -------------------------------------------------------------------------------- /connectors/webpac.js: -------------------------------------------------------------------------------- 1 | const cheerio = require('cheerio') 2 | const request = require('superagent') 3 | 4 | const common = require('../connectors/common') 5 | 6 | /** 7 | * Gets the object representing the service 8 | * @param {object} service 9 | */ 10 | exports.getService = service => common.getService(service) 11 | 12 | /** 13 | * Gets the libraries in the service based upon possible search and filters within the library catalogue 14 | * @param {object} service 15 | */ 16 | exports.getLibraries = async function (service) { 17 | const agent = request.agent() 18 | const responseLibraries = common.initialiseGetLibrariesResponse(service) 19 | 20 | try { 21 | const advancedSearchPageRequest = await agent 22 | .get(service.Url + 'search/X') 23 | .timeout(60000) 24 | const $ = cheerio.load(advancedSearchPageRequest.text) 25 | $('select[Name=searchscope] option').each((idx, option) => { 26 | if (common.isLibrary($(option).text().trim())) 27 | responseLibraries.libraries.push($(option).text().trim()) 28 | }) 29 | } catch (e) { 30 | responseLibraries.exception = e 31 | } 32 | 33 | return common.endResponse(responseLibraries) 34 | } 35 | 36 | /** 37 | * Retrieves the availability summary of an ISBN by library 38 | * @param {string} isbn 39 | * @param {object} service 40 | */ 41 | exports.searchByISBN = async function (isbn, service) { 42 | const responseHoldings = common.initialiseSearchByISBNResponse(service) 43 | responseHoldings.url = 44 | service.Url + 'search~S1/?searchtype=i&searcharg=' + isbn 45 | 46 | const agent = request.agent() 47 | const libs = {} 48 | 49 | try { 50 | const responseHoldingsRequest = await agent 51 | .get(responseHoldings.url) 52 | .timeout(60000) 53 | const $ = cheerio.load(responseHoldingsRequest.text) 54 | 55 | const id = $('#recordnum') 56 | responseHoldings.id = id.attr('href').replace('/record=', '') 57 | 58 | $('table.bibItems tr.bibItemsEntry').each(function (idx, tr) { 59 | const name = $(tr).find('td').eq(0).text().trim() 60 | const status = $(tr).find('td').eq(3).text().trim() 61 | if (!libs[name]) libs[name] = { available: 0, unavailable: 0 } 62 | status === 'AVAILABLE' || status === 'FOR LOAN' 63 | ? libs[name].available++ 64 | : libs[name].unavailable++ 65 | }) 66 | 67 | for (const l in libs) 68 | responseHoldings.availability.push({ 69 | library: l, 70 | available: libs[l].available, 71 | unavailable: libs[l].unavailable 72 | }) 73 | } catch (e) { 74 | responseHoldings.exception = e 75 | } 76 | 77 | return common.endResponse(responseHoldings) 78 | } 79 | -------------------------------------------------------------------------------- /tests/arena.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const idx = require('.') 4 | 5 | const t = 300000 6 | 7 | test('E09000004', async () => await idx.runTest('Bexley'), t) 8 | test('E06000017', async () => await idx.runTest('Rutland'), t) 9 | test( 10 | 'E06000022', 11 | async () => await idx.runTest('Bath and North East Somerset'), 12 | t 13 | ) 14 | test('E06000055', async () => await idx.runTest('Bedford'), t) 15 | test('E06000058', async () => await idx.runTest('Bournemouth, Christchurch and Poole'), t) 16 | test('E06000023', async () => await idx.runTest('Bristol City'), t) 17 | test('E06000056', async () => await idx.runTest('Central Bedfordshire'), t) 18 | test('E08000026', async () => await idx.runTest('Coventry'), t) 19 | test('E06000005', async () => await idx.runTest('Darlington'), t) 20 | test('E06000015', async () => await idx.runTest('Derby City'), t) 21 | test('E10000007', async () => await idx.runTest('Derbyshire'), t) 22 | test('E10000008', async () => await idx.runTest('Devon'), t) 23 | test('E08000017', async () => await idx.runTest('Doncaster'), t) 24 | test('E06000059', async () => await idx.runTest('Dorset'), t) 25 | test('E06000011', async () => await idx.runTest('East Riding of Yorkshire'), t) 26 | test('S12000036', async () => await idx.runTest('Edinburgh'), t) 27 | test('S12000046', async () => await idx.runTest('Glasgow'), t) 28 | test('Guernsey', async () => await idx.runTest('Guernsey'), t) 29 | test('E09000022', async () => await idx.runTest('Lambeth'), t) 30 | test('E06000016', async () => await idx.runTest('Leicester City'), t) 31 | test('E10000018', async () => await idx.runTest('Leicestershire'), t) 32 | test('E06000012', async () => await idx.runTest('North East Lincolnshire'), t) 33 | test('E06000013', async () => await idx.runTest('North Lincolnshire'), t) 34 | test('E06000061', async () => await idx.runTest('North Northamptonshire'), t) 35 | test('Northern Ireland', async () => await idx.runTest('Northern Ireland'), t) 36 | test('E06000018', async () => await idx.runTest('Nottingham City'), t) 37 | test('E10000024', async () => await idx.runTest('Nottinghamshire'), t) 38 | test('E06000024', async () => await idx.runTest('North Somerset'), t) 39 | test('E10000025', async () => await idx.runTest('Oxfordshire'), t) 40 | test('E06000026', async () => await idx.runTest('Plymouth'), t) 41 | test('E06000003', async () => await idx.runTest('Redcar and Cleveland'), t) 42 | test('E06000051', async () => await idx.runTest('Shropshire'), t) 43 | test('E10000027', async () => await idx.runTest('Somerset'), t) 44 | test('E06000025', async () => await idx.runTest('South Gloucestershire'), t) 45 | test('E06000020', async () => await idx.runTest('Telford and Wrekin'), t) 46 | test('E06000027', async () => await idx.runTest('Torbay'), t) 47 | test('E10000031', async () => await idx.runTest('Warwickshire'), t) 48 | test('E10000032', async () => await idx.runTest('West Sussex'), t) 49 | test('E06000062', async () => await idx.runTest('West Northamptonshire'), t) 50 | test('E06000054', async () => await idx.runTest('Wiltshire'), t) 51 | -------------------------------------------------------------------------------- /connectors/common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The majority of the get service call is just returning information that's in the service 3 | * object from data.json. Maintain a list here of what to return. 4 | * @param {object} service 5 | */ 6 | exports.getService = function (service) { 7 | return { 8 | code: service.Code, 9 | name: service.Name, 10 | type: service.Type, 11 | url: service.Url 12 | } 13 | } 14 | 15 | /** 16 | * Used for error handling and checking HTTP status 17 | * @param {*} error 18 | * @param {*} httpMessage 19 | */ 20 | exports.handleErrors = function (error, httpMessage) { 21 | if (httpMessage && (httpMessage.statusCode !== 200 && httpMessage.statusCode !== 302)) error = 'Web request error. Status code was ' + httpMessage.statusCode 22 | if (error) return true 23 | return false 24 | } 25 | 26 | /** 27 | * Test if a string is json 28 | * @param {string} str 29 | */ 30 | exports.isJsonString = function (str) { 31 | try { 32 | JSON.parse(str) 33 | } catch (e) { return false } 34 | return true 35 | } 36 | 37 | /** 38 | * Test if a string is a library 39 | * @param {string} str 40 | */ 41 | exports.isLibrary = function (str) { 42 | const nonLibraries = [ 43 | 'ALL', 44 | 'ANY', 45 | 'ADULT BOOKS', 46 | 'ALL BRANCHES', 47 | 'ALL HULL CITY LIBRARIES', 48 | 'ALL LOCATIONS', 49 | 'ALL LIBRARIES', 50 | 'ALL', 51 | 'ANY LIBRARY', 52 | 'AUDIO BOOKS', 53 | 'CHILDREN\'S BOOKS', 54 | 'CHOOSE ONE', 55 | 'DVDS', 56 | 'FICTION', 57 | 'HERE', 58 | 'INVALID KEY', 59 | 'LARGE PRINT', 60 | 'LOCAL HISTORY', 61 | 'NON-FICTION', 62 | 'SCHOOL LIBRARIES COLLECTIONS', 63 | 'SELECT AN ALTERNATIVE', 64 | 'SELECT BRANCH', 65 | 'SELECT DEFAULT BRANCH', 66 | 'SELECT LIBRARY', 67 | 'VIEW ENTIRE COLLECTION', 68 | 'YOUNG ADULT COLLECTION' 69 | ] 70 | return !nonLibraries.includes(str.toUpperCase()) 71 | } 72 | 73 | /** 74 | * Creates a new object to store results for the get libraries request 75 | * @param {object} service 76 | */ 77 | exports.initialiseGetLibrariesResponse = function (service) { 78 | const response = { service: service.Name, code: service.Code, libraries: [], start: new Date(), end: null } 79 | // Sometimes we have to use libraries that are hardcoded into the config 80 | if (service.Libraries) { 81 | for (const lib in service.Libraries) response.libraries.push(lib) 82 | } 83 | return response 84 | } 85 | 86 | /** 87 | * Creates a new object to store search results for the ISBN search 88 | * @param {object} service 89 | */ 90 | exports.initialiseSearchByISBNResponse = function (service) { 91 | return { id: null, service: service.Name, code: service.Code, availability: [], start: new Date(), end: null } 92 | } 93 | 94 | /** 95 | * Assigns a final timestamp to a request 96 | * @param {*} service 97 | */ 98 | exports.endResponse = function (request) { 99 | return { ...request, end: new Date() } 100 | } 101 | -------------------------------------------------------------------------------- /connectors/koha.v22.js: -------------------------------------------------------------------------------- 1 | const cheerio = require('cheerio') 2 | const request = require('superagent') 3 | 4 | const common = require('./common') 5 | 6 | const CAT_URL = 7 | 'Search/Results?lookfor=[ISBN]&searchIndex=Keyword&sort=relevance&view=rss&searchSource=local' 8 | const LIBS_URL = 9 | 'Union/Search?view=list&showCovers=on&lookfor=&searchIndex=advanced&searchSource=local' 10 | 11 | /** 12 | * Gets the object representing the service 13 | * @param {object} service 14 | */ 15 | exports.getService = service => common.getService(service) 16 | 17 | /** 18 | * Gets the libraries in the service based upon possible search and filters within the library catalogue 19 | * @param {object} service 20 | */ 21 | exports.getLibraries = async function (service) { 22 | const responseLibraries = common.initialiseGetLibrariesResponse(service) 23 | 24 | try { 25 | const agent = request.agent() 26 | const url = service.Url + LIBS_URL 27 | 28 | const libraryPageRequest = await agent.get(url).timeout(60000) 29 | const $ = cheerio.load(libraryPageRequest.text) 30 | 31 | $('option').each((idx, option) => { 32 | if ( 33 | (option.attribs.value.startsWith('owning_location_main:') || 34 | option.attribs.value.startsWith('owning_location:')) && 35 | common.isLibrary($(option).text().trim()) 36 | ) { 37 | responseLibraries.libraries.push($(option).text().trim()) 38 | } 39 | }) 40 | } catch (e) { 41 | responseLibraries.exception = e 42 | } 43 | 44 | return common.endResponse(responseLibraries) 45 | } 46 | 47 | /** 48 | * Retrieves the availability summary of an ISBN by library 49 | * @param {string} isbn 50 | * @param {object} service 51 | */ 52 | exports.searchByISBN = async function (isbn, service) { 53 | const responseHoldings = common.initialiseSearchByISBNResponse(service) 54 | responseHoldings.url = service.Url 55 | 56 | try { 57 | const agent = request.agent() 58 | 59 | const searchUrl = service.Url + CAT_URL.replace('[ISBN]', isbn) 60 | const searchPageRequest = await agent.get(searchUrl).timeout(30000) 61 | let $ = cheerio.load(searchPageRequest.text, { 62 | normalizeWhitespace: true, 63 | xmlMode: true 64 | }) 65 | responseHoldings.url = $('item > link').first().text() 66 | 67 | let bibLink = $('guid').text() 68 | if (!bibLink) return common.endResponse(responseHoldings) 69 | 70 | responseHoldings.id = bibLink.substring(bibLink.lastIndexOf('/') + 1) 71 | bibLink = `${bibLink}/AJAX?method=getCopyDetails&format=Reference&recordId=${responseHoldings.id}` 72 | 73 | const itemPageRequest = await agent.get(bibLink).timeout(30000) 74 | $ = cheerio.load(itemPageRequest.body.modalBody) 75 | 76 | const libs = {} 77 | 78 | $('table') 79 | .find('tbody > tr') 80 | .each((idx, row) => { 81 | const lib = $(row).find('td.notranslate').first().text().trim() 82 | if (!libs[lib]) libs[lib] = { available: 0, unavailable: 0 } 83 | const quantity = $(row).find('td').first().text().trim().split(' of ') 84 | libs[lib].available += parseInt(quantity[0]) 85 | libs[lib].unavailable += parseInt(quantity[1]) - parseInt(quantity[0]) 86 | }) 87 | 88 | for (const l in libs) 89 | responseHoldings.availability.push({ 90 | library: l, 91 | available: libs[l].available, 92 | unavailable: libs[l].unavailable 93 | }) 94 | } catch (e) { 95 | responseHoldings.exception = e 96 | } 97 | 98 | return common.endResponse(responseHoldings) 99 | } 100 | -------------------------------------------------------------------------------- /connectors/spydus.js: -------------------------------------------------------------------------------- 1 | const cheerio = require('cheerio') 2 | const request = require('superagent') 3 | 4 | const common = require('../connectors/common') 5 | 6 | const LIBS_URL = 'cgi-bin/spydus.exe/MSGTRN/WPAC/COMB' 7 | const SEARCH_URL = 'cgi-bin/spydus.exe/ENQ/WPAC/BIBENQ?NRECS=1&ISBN=' 8 | 9 | /** 10 | * Gets the object representing the service 11 | * @param {object} service 12 | */ 13 | exports.getService = service => common.getService(service) 14 | 15 | /** 16 | * Gets the libraries in the service based upon possible search and filters within the library catalogue 17 | * @param {object} service 18 | */ 19 | exports.getLibraries = async function (service) { 20 | const agent = request.agent() 21 | const responseLibraries = common.initialiseGetLibrariesResponse(service) 22 | 23 | try { 24 | let libsUrl = service.Url + LIBS_URL 25 | if (service.OpacReference) { 26 | libsUrl = libsUrl.replace('WPAC', service.OpacReference) 27 | } 28 | if (service.CatalogueReference) { 29 | libsUrl = libsUrl.replace('COMB', service.CatalogueReference) 30 | } 31 | const libsPageRequest = await agent 32 | .get(libsUrl) 33 | .set({ Cookie: 'ALLOWCOOKIES_443=1' }) 34 | .timeout(60000) 35 | 36 | const $ = cheerio.load(libsPageRequest.text) 37 | $('#LOC option').each(function (idx, option) { 38 | if (common.isLibrary($(option).text().trim())) 39 | responseLibraries.libraries.push($(option).text().trim()) 40 | }) 41 | } catch (e) { 42 | responseLibraries.exception = e 43 | } 44 | 45 | return common.endResponse(responseLibraries) 46 | } 47 | 48 | /** 49 | * Retrieves the availability summary of an ISBN by library 50 | * @param {string} isbn 51 | * @param {object} service 52 | */ 53 | exports.searchByISBN = async function (isbn, service) { 54 | let holdingsUrl = service.Url + SEARCH_URL + isbn 55 | if (service.OpacReference) { 56 | holdingsUrl = holdingsUrl.replace('WPAC', service.OpacReference) 57 | } 58 | 59 | const agent = request.agent() 60 | const responseHoldings = common.initialiseSearchByISBNResponse(service) 61 | responseHoldings.url = holdingsUrl 62 | 63 | try { 64 | const itemPageRequest = await await agent.get(holdingsUrl).timeout(60000) 65 | let $ = cheerio.load(itemPageRequest.text) 66 | if ($('#result-content-list').length === 0) 67 | return common.endResponse(responseHoldings) 68 | 69 | responseHoldings.id = $('.card.card-list').first().find('a').attr('name') 70 | 71 | if (!responseHoldings.id) { 72 | responseHoldings.id = $('.card.card-list') 73 | .first() 74 | .find('input.form-check-input') 75 | .attr('value') 76 | } 77 | 78 | const availabilityUrl = $('.card-text.availability') 79 | .first() 80 | .find('a') 81 | .attr('href') 82 | const availabilityRequest = await agent 83 | .get(service.Url + availabilityUrl) 84 | .timeout(60000) 85 | 86 | $ = cheerio.load(availabilityRequest.text) 87 | 88 | const libs = {} 89 | $('table tr') 90 | .slice(1) 91 | .each(function (i, tr) { 92 | const name = $(tr).find('td').eq(0).text().trim() 93 | const status = $(tr).find('td').eq(3).text().trim() 94 | if (!libs[name]) libs[name] = { available: 0, unavailable: 0 } 95 | status === 'Available' 96 | ? libs[name].available++ 97 | : libs[name].unavailable++ 98 | }) 99 | for (const l in libs) 100 | responseHoldings.availability.push({ 101 | library: l, 102 | available: libs[l].available, 103 | unavailable: libs[l].unavailable 104 | }) 105 | } catch (e) { 106 | responseHoldings.exception = e 107 | } 108 | 109 | return common.endResponse(responseHoldings) 110 | } 111 | -------------------------------------------------------------------------------- /connectors/koha.v20.js: -------------------------------------------------------------------------------- 1 | const cheerio = require('cheerio') 2 | const request = require('superagent') 3 | const UserAgent = require('user-agents') 4 | 5 | const common = require('./common') 6 | 7 | const CAT_URL = 'cgi-bin/koha/opac-search.pl?format=rss2&idx=nb&q=' 8 | const LIBS_URL = 9 | 'cgi-bin/koha/opac-search.pl?[MULTIBRANCH]do=Search&expand=holdingbranch#holdingbranch_id' 10 | const HEADER = { 11 | 'User-Agent': new UserAgent().toString(), 12 | } 13 | 14 | /** 15 | * Gets the object representing the service 16 | * @param {object} service 17 | */ 18 | exports.getService = service => common.getService(service) 19 | 20 | /** 21 | * Gets the libraries in the service based upon possible search and filters within the library catalogue 22 | * @param {object} service 23 | */ 24 | exports.getLibraries = async function (service) { 25 | const responseLibraries = common.initialiseGetLibrariesResponse(service) 26 | 27 | try { 28 | const agent = request.agent() 29 | const url = 30 | service.Url + 31 | LIBS_URL.replace( 32 | '[MULTIBRANCH]', 33 | service.MultiBranchLimit 34 | ? 'multibranchlimit=' + service.MultiBranchLimit + '&' 35 | : '' 36 | ) 37 | 38 | const libraryPageRequest = await agent.get(url).set(HEADER).timeout(60000) 39 | const $ = cheerio.load(libraryPageRequest.text) 40 | 41 | $('#branchloop option').each((idx, option) => { 42 | if (common.isLibrary($(option).text())) 43 | responseLibraries.libraries.push($(option).text().trim()) 44 | }) 45 | $('li#holdingbranch_id ul li span.facet-label').each((idx, label) => { 46 | responseLibraries.libraries.push($(label).text().trim()) 47 | }) 48 | $('li#homebranch_id ul li span.facet-label').each((idx, label) => { 49 | responseLibraries.libraries.push($(label).text().trim()) 50 | }) 51 | } catch (e) { 52 | responseLibraries.exception = e 53 | } 54 | 55 | return common.endResponse(responseLibraries) 56 | } 57 | 58 | /** 59 | * Retrieves the availability summary of an ISBN by library 60 | * @param {string} isbn 61 | * @param {object} service 62 | */ 63 | exports.searchByISBN = async function (isbn, service) { 64 | const responseHoldings = common.initialiseSearchByISBNResponse(service) 65 | responseHoldings.url = service.Url 66 | 67 | try { 68 | const agent = request.agent() 69 | 70 | const searchPageRequest = await agent 71 | .get(service.Url + CAT_URL + isbn) 72 | .set(HEADER) 73 | .timeout(30000) 74 | let $ = cheerio.load(searchPageRequest.text, { 75 | normalizeWhitespace: true, 76 | xmlMode: true 77 | }) 78 | responseHoldings.url = $('link').first().text() 79 | 80 | const bibLink = $('guid').text() 81 | if (!bibLink) return common.endResponse(responseHoldings) 82 | 83 | responseHoldings.id = bibLink.substring(bibLink.lastIndexOf('=') + 1) 84 | responseHoldings.url = bibLink 85 | 86 | const itemPageRequest = await agent 87 | .get(bibLink + '&viewallitems=1') 88 | .set(HEADER) 89 | .timeout(30000) 90 | $ = cheerio.load(itemPageRequest.text) 91 | 92 | const libs = {} 93 | $('#holdingst tbody, .holdingst tbody') 94 | .find('tr') 95 | .each((idx, table) => { 96 | const lib = $(table).find('td.location span span').first().text().trim() 97 | if (!libs[lib]) libs[lib] = { available: 0, unavailable: 0 } 98 | $(table).find('td.status span').text().trim() === 'Available' 99 | ? libs[lib].available++ 100 | : libs[lib].unavailable++ 101 | }) 102 | for (const l in libs) 103 | responseHoldings.availability.push({ 104 | library: l, 105 | available: libs[l].available, 106 | unavailable: libs[l].unavailable 107 | }) 108 | } catch (e) { 109 | responseHoldings.exception = e 110 | } 111 | 112 | return common.endResponse(responseHoldings) 113 | } 114 | -------------------------------------------------------------------------------- /connectors/luci.js: -------------------------------------------------------------------------------- 1 | const request = require('superagent') 2 | 3 | const common = require('./common') 4 | 5 | /** 6 | * Gets the object representing the service 7 | * @param {object} service 8 | */ 9 | exports.getService = service => common.getService(service) 10 | 11 | const getLuciLibrariesInternal = async function (service) { 12 | const agent = request.agent() 13 | const response = { 14 | libraries: [] 15 | } 16 | 17 | try { 18 | let resp = await agent.get(`${service.Url}${service.Home}`).timeout(20000) 19 | const frontEndId = /\/_next\/static\/([^\/]+)\/_buildManifest.js/gm.exec( 20 | resp.text 21 | )[1] 22 | 23 | resp = await agent 24 | .get(`${service.Url}_next/data/${frontEndId}/user/register.json`) 25 | .timeout(20000) 26 | const libraries = resp.body.pageProps.patronFields.find( 27 | x => x.code === 'patron_homeLocation' 28 | ).optionList 29 | 30 | for (const library of libraries) { 31 | response.libraries.push({ 32 | name: library.value.trim(), 33 | code: library.key.trim() 34 | }) 35 | } 36 | } catch (e) { 37 | response.exception = e 38 | } 39 | 40 | return response 41 | } 42 | 43 | /** 44 | * Gets the libraries in the service based upon possible search and filters within the library catalogue 45 | * @param {object} service 46 | */ 47 | exports.getLibraries = async function (service) { 48 | const responseLibraries = common.initialiseGetLibrariesResponse(service) 49 | const libs = await getLuciLibrariesInternal(service) 50 | 51 | responseLibraries.exception = libs.exception 52 | responseLibraries.libraries = libs.libraries.map(x => x.name) 53 | 54 | return common.endResponse(responseLibraries) 55 | } 56 | 57 | /** 58 | * Retrieves the availability summary of an ISBN by library 59 | * @param {string} isbn 60 | * @param {object} service 61 | */ 62 | exports.searchByISBN = async function (isbn, service) { 63 | const responseHoldings = common.initialiseSearchByISBNResponse(service) 64 | 65 | try { 66 | const agent = request.agent() 67 | let resp = await agent.get(`${service.Url}${service.Home}`).timeout(20000) 68 | 69 | const appId = /\?appid=([a-f0-9\-]+)/gm.exec(resp.text)[1] 70 | 71 | resp = await agent 72 | .post(`${service.Url}api/manifestations/searchresult`) 73 | .send({ 74 | searchTerm: isbn, 75 | searchTarget: '', 76 | searchField: '', 77 | sortField: 'any', 78 | searchLimit: '196', 79 | offset: 0, 80 | count: 40 81 | }) 82 | .set('Content-Type', 'application/json') 83 | .set('solus-app-id', appId) 84 | .timeout(20000) 85 | 86 | const result = resp.body.records.find(x => x.isbnList.includes(isbn)) 87 | 88 | if (!result || result.eContent) { 89 | return common.endResponse(responseHoldings) 90 | } 91 | 92 | responseHoldings.id = result.recordID 93 | responseHoldings.url = `${service.Url}manifestations/${result.recordID}` 94 | 95 | resp = await agent 96 | .get(`${service.Url}api/record?id=${result.recordID}&source=ILSWS`) 97 | .set('solus-app-id', appId) 98 | .timeout(20000) 99 | 100 | let libraries = resp.body.data.copies.map(x => x.location.locationName) 101 | 102 | // Get unique library values. 103 | libraries = libraries.filter((v, i, s) => s.indexOf(v) === i) 104 | 105 | for (const library of libraries) { 106 | responseHoldings.availability.push({ 107 | library, 108 | available: resp.body.data.copies.filter( 109 | x => x.location.locationName === library && x.available 110 | ).length, 111 | unavailable: resp.body.data.copies.filter( 112 | x => x.location.locationName === library && !x.available 113 | ).length 114 | }) 115 | } 116 | } catch (e) { 117 | responseHoldings.exception = e 118 | } 119 | 120 | return common.endResponse(responseHoldings) 121 | } 122 | -------------------------------------------------------------------------------- /connectors/koha.v23.js: -------------------------------------------------------------------------------- 1 | const cheerio = require('cheerio') 2 | const request = require('superagent') 3 | 4 | const initCycleTLS = require('cycletls') 5 | 6 | const common = require('./common') 7 | 8 | const CAT_URL = 9 | 'Search/Results?lookfor=[ISBN]&searchIndex=Keyword&sort=relevance&view=rss&searchSource=local' 10 | const LIBS_URL = 11 | 'Union/Search?view=list&showCovers=on&lookfor=&searchIndex=advanced&searchSource=local' 12 | 13 | /** 14 | * Gets the object representing the service 15 | * @param {object} service 16 | */ 17 | exports.getService = service => common.getService(service) 18 | 19 | /** 20 | * Gets the libraries in the service based upon possible search and filters within the library catalogue 21 | * @param {object} service 22 | */ 23 | exports.getLibraries = async function (service) { 24 | const responseLibraries = common.initialiseGetLibrariesResponse(service) 25 | 26 | try { 27 | const agent = request.agent() 28 | const url = service.Url + (service.LibsUrl || LIBS_URL) 29 | 30 | let libraryPage = null 31 | if (service.Cloudflare) { 32 | const cycleTLS = await initCycleTLS() 33 | libraryPage = await cycleTLS( 34 | url, 35 | { 36 | ja3: '771,4865-4867-4866-49195-49199-52393-52392-49196-49200-49162-49161-49171-49172-51-57-47-53-10,0-23-65281-10-11-35-16-5-51-43-13-45-28-21,29-23-24-25-256-257,0', 37 | userAgent: 'LibraryCatalogues' 38 | }, 39 | 'get' 40 | ).body 41 | } else { 42 | libraryPage = (await agent.get(url).timeout(30000)).text 43 | } 44 | 45 | const $ = cheerio.load(libraryPage) 46 | 47 | $('option').each((idx, option) => { 48 | if ( 49 | (option.attribs.value.startsWith('owning_location:') || 50 | option.attribs.value.startsWith('branch:')) && 51 | common.isLibrary($(option).text().trim()) 52 | ) { 53 | responseLibraries.libraries.push($(option).text().trim()) 54 | } 55 | }) 56 | } catch (e) { 57 | responseLibraries.exception = e 58 | } 59 | 60 | return common.endResponse(responseLibraries) 61 | } 62 | 63 | /** 64 | * Retrieves the availability summary of an ISBN by library 65 | * @param {string} isbn 66 | * @param {object} service 67 | */ 68 | exports.searchByISBN = async function (isbn, service) { 69 | const responseHoldings = common.initialiseSearchByISBNResponse(service) 70 | responseHoldings.url = service.Url 71 | 72 | try { 73 | const agent = request.agent() 74 | 75 | let searchUrl = service.Url + (service.CatUrl || CAT_URL) 76 | searchUrl = searchUrl.replace('[ISBN]', isbn) 77 | const searchPageRequest = await agent.get(searchUrl).timeout(30000) 78 | let $ = cheerio.load(searchPageRequest.text, { 79 | normalizeWhitespace: true, 80 | xmlMode: true 81 | }) 82 | responseHoldings.url = $('item > link').first().text() 83 | 84 | let bibLink = $('guid').text() 85 | if (!bibLink) return common.endResponse(responseHoldings) 86 | 87 | responseHoldings.id = bibLink.substring(bibLink.lastIndexOf('/') + 1) 88 | bibLink = `${bibLink}/AJAX?method=getCopyDetails&format=Reference&recordId=${responseHoldings.id}` 89 | 90 | const itemPageRequest = await agent.get(bibLink).timeout(30000) 91 | $ = cheerio.load(itemPageRequest.body.modalBody) 92 | 93 | const libs = {} 94 | 95 | $('table') 96 | .find('tbody > tr') 97 | .each((idx, row) => { 98 | const lib = $(row).find('td.notranslate').first().text().trim() 99 | if (!libs[lib]) libs[lib] = { available: 0, unavailable: 0 } 100 | const quantity = $(row).find('td').first().text().trim().split(' of ') 101 | libs[lib].available += parseInt(quantity[0]) 102 | libs[lib].unavailable += parseInt(quantity[1]) - parseInt(quantity[0]) 103 | }) 104 | 105 | for (const l in libs) 106 | responseHoldings.availability.push({ 107 | library: l, 108 | available: libs[l].available, 109 | unavailable: libs[l].unavailable 110 | }) 111 | } catch (e) { 112 | responseHoldings.exception = e 113 | } 114 | 115 | return common.endResponse(responseHoldings) 116 | } 117 | -------------------------------------------------------------------------------- /connectors/prism3.js: -------------------------------------------------------------------------------- 1 | const request = require('superagent') 2 | const cheerio = require('cheerio') 3 | 4 | const common = require('../connectors/common') 5 | 6 | const AVAILABLE_STATUSES = [ 7 | 'http://schema.org/InStock', 8 | 'http://schema.org/InStoreOnly' 9 | ] 10 | const HEADER = { 'Content-Type': 'text/xml; charset=utf-8' } 11 | const DEEP_LINK = 'items?query=' 12 | 13 | /** 14 | * Gets the object representing the service 15 | * @param {object} service 16 | */ 17 | exports.getService = service => common.getService(service) 18 | 19 | /** 20 | * Gets the libraries in the service based upon possible search and filters within the library catalogue 21 | * @param {object} service 22 | */ 23 | exports.getLibraries = async function (service) { 24 | const responseLibraries = common.initialiseGetLibrariesResponse(service) 25 | 26 | try { 27 | const agent = request.agent() 28 | 29 | const advancedSearchPageRequest = await agent 30 | .get(service.Url + 'advancedsearch?target=catalogue') 31 | .timeout(60000) 32 | const $ = cheerio.load(advancedSearchPageRequest.text) 33 | 34 | $('#locdd option').each((idx, option) => { 35 | if (common.isLibrary($(option).text().trim())) 36 | responseLibraries.libraries.push($(option).text().trim()) 37 | }) 38 | } catch (e) { 39 | responseLibraries.exception = e 40 | } 41 | 42 | return common.endResponse(responseLibraries) 43 | } 44 | 45 | /** 46 | * Retrieves the availability summary of an ISBN by library 47 | * @param {string} isbn 48 | * @param {object} service 49 | */ 50 | exports.searchByISBN = async function (isbn, service) { 51 | const responseHoldings = common.initialiseSearchByISBNResponse(service) 52 | responseHoldings.url = service.Url + DEEP_LINK + isbn 53 | 54 | try { 55 | const agent = request.agent() 56 | 57 | let $ = null 58 | const searchRequest = await agent 59 | .get(service.Url + 'items.json?query=' + isbn) 60 | .set(HEADER) 61 | .timeout(30000) 62 | if (searchRequest.body.length === 0) 63 | return common.endResponse(responseHoldings) 64 | 65 | let itemUrl = '' 66 | 67 | for (const k of Object.keys(searchRequest.body)) { 68 | let eBook = true 69 | 70 | if (k.indexOf('/items/') > 0) { 71 | itemUrl = k 72 | 73 | for (const key of Object.keys(searchRequest.body[k])) { 74 | const item = searchRequest.body[k][key] 75 | 76 | switch (key) { 77 | case 'http://purl.org/dc/elements/1.1/format': 78 | item.forEach(format => { 79 | // One record can contain multiple formats. If *any* aren't 80 | // an eBook, we should get the item details. 81 | if (format.value !== 'eBook') { 82 | eBook = false 83 | } 84 | }) 85 | break 86 | case 'http://purl.org/dc/terms/identifier': 87 | responseHoldings.id = item[0].value 88 | break 89 | } 90 | } 91 | 92 | if (itemUrl && eBook) { 93 | itemUrl = '' 94 | // Try the next record, just in case.. 95 | } else { 96 | // We've found what we needed - leave the "for" loop. 97 | break 98 | } 99 | } 100 | } 101 | 102 | if (itemUrl !== '') { 103 | const itemRequest = await agent.get(itemUrl).timeout() 104 | $ = cheerio.load(itemRequest.text) 105 | } else { 106 | return common.endResponse(responseHoldings) 107 | } 108 | 109 | $('#availability ul.options') 110 | .find('li') 111 | .each((idx, li) => { 112 | const libr = { 113 | library: $(li).find('h3 span span').text().trim(), 114 | available: 0, 115 | unavailable: 0 116 | } 117 | $(li) 118 | .find('div.jsHidden table tbody tr') 119 | .each((i, tr) => { 120 | const status = $(tr) 121 | .find("link[itemprop = 'availability']") 122 | .attr('href') 123 | AVAILABLE_STATUSES.includes(status) 124 | ? libr.available++ 125 | : libr.unavailable++ 126 | }) 127 | responseHoldings.availability.push(libr) 128 | }) 129 | } catch (e) { 130 | responseHoldings.exception = e 131 | } 132 | 133 | return common.endResponse(responseHoldings) 134 | } 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Public library catalogues Node.JS Library 2 | 3 | A Node JS library for searching UK public library catalogues. This was designed to be used in other projects that need to search multiple library catalogues. 4 | 5 | ## Description 6 | 7 | In the UK there are about 200 public library services, each with their own Library Management System, and associated Online Public Access Catalogue (OPAC) - aside from some that share systems. 8 | 9 | Despite so many, there are relatively few types of library systems, and fewer suppliers. This project aims to define the interactions with each type of web catalogue in order to automate common processes. Such as searching for a book. 10 | 11 | This will provide data aggregation opportunities such as being able to query the whole UK for the availability of a particular book. Or it could provide functionality to manage a user's account across all their library accounts, such as automating book renewals. 12 | 13 | ## Library service data 14 | 15 | A list of UK public library authorities is included in the **data.json** file. This has the library authority name and the **type** of library service, along with specific data required to search that service e.g. the web URL. 16 | 17 | It includes the GSS code for each authority. This allows it to be combined with other datasets that may be published elsewhere. 18 | 19 | For example: 20 | 21 | | Name | Code | Type | URL | 22 | | ------------- | --------- | ------ | ---------------------------------- | 23 | | Aberdeen City | S12000033 | spydus | https://aberdeencity.spydus.co.uk/ | 24 | 25 | ## Build 26 | 27 | The project uses Node Package Manager (NPM) for package management. On downloading a copy of the project the required dependencies should be installed. Assuming [Node](https://nodejs.org/en/) is already installed, to build: 28 | 29 | ```bash 30 | npm install 31 | ``` 32 | 33 | ## Tests 34 | 35 | Run these using Jest. For each library service, five ISBNs are defined in `tests.json`. The tests require only one ISBN lookup to be successful (since books can drop out of circulation and we don't want automated tests to fail frequently for non-functional reasons). 36 | 37 | ## Usage 38 | 39 | The project implements the following methods 40 | 41 | | Method | Description | 42 | | ------------ | --------------------------------------------------------------------- | 43 | | Services | Returns stored data about library services (authorities). | 44 | | Libraries | Returns branch/location information, taken from the online catalogue. | 45 | | Availability | Returns availability of a particular book. | 46 | 47 | ### Services 48 | 49 | Returns selected contents of the data.json file for each service. This can be useful if a developer wished to create an interface that listed the library authorities in a filter. 50 | 51 | | Method | Description | 52 | | ------------------------ | -------------------------------------------------------------------------------------------------- | 53 | | .services(serviceFilter) | Returns a list of library authorities. The service filter filters by name or code and is optional. | 54 | 55 | ### Libraries 56 | 57 | Returns a list of the library service points in each library service. This may include mobile libraries, and different locations within individual buildings. 58 | 59 | | Method | Description | 60 | | ------------------------- | --------------------------------------------------------------------------------------------------------- | 61 | | .libraries(serviceFilter) | Returns a list of libraries for each service. The service filter filters by name or code and is optional. | 62 | 63 | ### Availability 64 | 65 | Returns data showing the number of available/unavailable copies of the relevant title in each library service point, for each library service. 66 | 67 | | Method | Description | 68 | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | 69 | | .availability(isbn, serviceFilter) | Retrieves availability of a particular title by passing in ISBN. The service filter filters by name or code and is optional. | 70 | 71 | ## Licence 72 | 73 | Original code licensed with [MIT Licence](Licence.txt). 74 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Description: Main entry point for the catalogue library service 2 | 3 | const async = require('async') 4 | 5 | const syswidecas = require('@small-tech/syswide-cas') 6 | // Intermediate certificate that's often incomplete in SSL chains. 7 | syswidecas.addCAs('./SectigoRSADomainValidationSecureServerCA.cer') 8 | 9 | // Catalogue integration connectors 10 | const arenav7 = require('./connectors/arena.v7') 11 | const arenav8 = require('./connectors/arena.v8') 12 | const aspen = require('./connectors/aspen') 13 | const durham = require('./connectors/durham') 14 | const enterprise = require('./connectors/enterprise') 15 | const iguana = require('./connectors/iguana') 16 | const kohav20 = require('./connectors/koha.v20') 17 | const kohav22 = require('./connectors/koha.v22') 18 | const kohav23 = require('./connectors/koha.v23') 19 | const luci = require('./connectors/luci') 20 | const prism3 = require('./connectors/prism3') 21 | const spydus = require('./connectors/spydus') 22 | const webpac = require('./connectors/webpac') 23 | 24 | // Other service connectors 25 | const libThing = require('./connectors/librarything') 26 | const openLibrary = require('./connectors/openlibrary') 27 | 28 | // Our data file of library services and their catalogue integrations 29 | const data = require('./data/data.json') 30 | 31 | const serviceFunctions = { 32 | arenav7, 33 | arenav8, 34 | aspen, 35 | durham, 36 | enterprise, 37 | iguana, 38 | kohav20, 39 | kohav22, 40 | kohav23, 41 | luci, 42 | prism3, 43 | spydus, 44 | webpac 45 | } 46 | 47 | const getServiceFunction = service => { 48 | return serviceFunctions[service.Type + (service.Version || '')] 49 | } 50 | 51 | const getLibraryServicesFromFilter = serviceFilter => { 52 | return data.LibraryServices.filter(service => { 53 | return ( 54 | service.Type !== '' && 55 | (!serviceFilter || 56 | service.Name === serviceFilter || 57 | service.Code === serviceFilter) 58 | ) 59 | }) 60 | } 61 | 62 | /** 63 | * Gets library service data 64 | * @param {String} serviceFilter An optional service to filter by using either code or name 65 | * @param {Object[]} serviceResults An array of library services 66 | */ 67 | exports.services = async serviceFilter => { 68 | const services = getLibraryServicesFromFilter(serviceFilter).map(service => { 69 | return async () => { 70 | return getServiceFunction(service).getService(service) 71 | } 72 | }) 73 | 74 | const serviceResults = await async.parallel(services) 75 | return serviceResults 76 | } 77 | 78 | /** 79 | * Gets individual library service point data 80 | * @param {String} serviceFilter An optional service to filter by using either code or name 81 | * @param {Object[]} libraryServicePoints An array of library service points 82 | */ 83 | exports.libraries = async serviceFilter => { 84 | const searches = data.LibraryServices.filter(service => { 85 | return ( 86 | service.Type !== '' && 87 | (!serviceFilter || 88 | service.Name === serviceFilter || 89 | service.Code === serviceFilter) 90 | ) 91 | }).map(service => { 92 | return async () => { 93 | const resp = await getServiceFunction(service).getLibraries(service) 94 | return resp 95 | } 96 | }) 97 | 98 | const libraryServicePoints = await async.parallel(searches) 99 | return libraryServicePoints 100 | } 101 | 102 | /** 103 | * Gets ISBN availability information for library service points 104 | * @param {String} isbn The ISBN to search for 105 | * @param {String} serviceFilter An optional service to filter by using either code or name 106 | * @param {Object[]} availability The availability of the ISBN by service 107 | */ 108 | exports.availability = async (isbn, serviceFilter) => { 109 | const searches = data.LibraryServices.filter(service => { 110 | return ( 111 | service.Type !== '' && 112 | (!serviceFilter || 113 | service.Name === serviceFilter || 114 | service.Code === serviceFilter) 115 | ) 116 | }).map(service => { 117 | return async () => { 118 | const resp = await getServiceFunction(service).searchByISBN(isbn, service) 119 | return resp 120 | } 121 | }) 122 | 123 | const availability = await async.parallel(searches) 124 | return availability 125 | } 126 | 127 | /** 128 | * Gets results from the LibraryThing ISBN service 129 | * @param {String} isbn The ISBN to search for 130 | * @param {String[]} isbnsAn array of ISBNs 131 | */ 132 | exports.thingISBN = async isbn => { 133 | const thingData = await libThing.thingISBN(isbn) 134 | return thingData.isbns 135 | } 136 | 137 | /** 138 | * Gets results from open libraries free text search 139 | * @param {String} query The query to search with 140 | * @param {Object} openLibData The search response from open library 141 | */ 142 | exports.openLibrarySearch = async query => { 143 | const openLibData = await openLibrary.search(query) 144 | return openLibData 145 | } 146 | -------------------------------------------------------------------------------- /connectors/durham.js: -------------------------------------------------------------------------------- 1 | const request = require('superagent') 2 | const cheerio = require('cheerio') 3 | const querystring = require('querystring') 4 | const uuid = require('uuid') 5 | 6 | const common = require('../connectors/common') 7 | 8 | /** 9 | * Gets the object representing the service 10 | * @param {object} service 11 | */ 12 | exports.getService = service => common.getService(service) 13 | 14 | /** 15 | * Gets the libraries in the service based upon possible search and filters within the library catalogue 16 | * @param {object} service 17 | */ 18 | exports.getLibraries = async function (service) { 19 | const agent = request.agent() 20 | const responseLibraries = common.initialiseGetLibrariesResponse(service) 21 | 22 | try { 23 | await agent.get(service.Url).timeout(20000) 24 | await agent 25 | .post( 26 | service.Url + 27 | 'pgLogin.aspx?CheckJavascript=1&AspxAutoDetectCookieSupport=1' 28 | ) 29 | .timeout(20000) 30 | const libraries = await agent.get(service.Url + 'pgLib.aspx').timeout(20000) 31 | const $ = cheerio.load(libraries.text) 32 | $('ol.list-unstyled li a').each((i, tag) => 33 | responseLibraries.libraries.push($(tag).text()) 34 | ) 35 | } catch (e) { 36 | responseLibraries.exception = e 37 | } 38 | 39 | return common.endResponse(responseLibraries) 40 | } 41 | 42 | /** 43 | * Retrieves the availability summary of an ISBN by library 44 | * @param {string} isbn 45 | * @param {object} service 46 | */ 47 | exports.searchByISBN = async function (isbn, service) { 48 | const responseHoldings = common.initialiseSearchByISBNResponse(service) 49 | responseHoldings.id = uuid.v4() 50 | 51 | try { 52 | const agent = request.agent() 53 | 54 | const headers = { 55 | 'Content-Type': 'application/x-www-form-urlencoded' 56 | } 57 | 58 | await agent.get(service.Url).timeout(20000) 59 | await agent 60 | .post(service.Url + 'pgLogin.aspx?CheckJavascript=1') 61 | .timeout(20000) 62 | const cataloguePage = await agent 63 | .post(service.Url + 'pgCatKeywordSearch.aspx') 64 | .timeout(20000) 65 | let $ = cheerio.load(cataloguePage.text) 66 | 67 | let aspNetForm = { 68 | __VIEWSTATE: $('input[name=__VIEWSTATE]').val(), 69 | __VIEWSTATEGENERATOR: $('input[name=__VIEWSTATEGENERATOR]').val(), 70 | __EVENTVALIDATION: $('input[name=__EVENTVALIDATION]').val(), 71 | ctl00$ctl00$cph1$cph2$cbBooks: 'on', 72 | ctl00$ctl00$cph1$cph2$Keywords: isbn, 73 | ctl00$ctl00$cph1$cph2$btSearch: 'Search' 74 | } 75 | 76 | const resultPage = await agent 77 | .post(service.Url + 'pgCatKeywordSearch.aspx') 78 | .send(querystring.stringify(aspNetForm)) 79 | .set(headers) 80 | .timeout(20000) 81 | $ = cheerio.load(resultPage.text) 82 | const resultPageUrl = resultPage.redirects[0] 83 | 84 | if ($('#cph1_cph2_lvResults_lnkbtnTitle_0').length === 0) 85 | return common.endResponse(responseHoldings) 86 | aspNetForm = { 87 | __EVENTARGUMENT: '', 88 | __EVENTTARGET: 'ctl00$ctl00$cph1$cph2$lvResults$ctrl0$lnkbtnTitle', 89 | __LASTFOCUS: '', 90 | __VIEWSTATE: $('input[name=__VIEWSTATE]').val(), 91 | __VIEWSTATEENCRYPTED: '', 92 | __VIEWSTATEGENERATOR: $('input[name=__VIEWSTATEGENERATOR]').val(), 93 | ctl00$ctl00$cph1$cph2$lvResults$DataPagerEx2$ctl00$ctl00: 10 94 | } 95 | 96 | const itemPage = await agent 97 | .post(resultPageUrl) 98 | .send(querystring.stringify(aspNetForm)) 99 | .set(headers) 100 | .timeout(20000) 101 | $ = cheerio.load(itemPage.text) 102 | 103 | const itemPageUrl = 104 | itemPage.redirects.length > 0 ? itemPage.redirects[0] : resultPageUrl 105 | 106 | aspNetForm = { 107 | __EVENTARGUMENT: '', 108 | __EVENTTARGET: '', 109 | __EVENTVALIDATION: $('input[name=__EVENTVALIDATION]').val(), 110 | __LASTFOCUS: '', 111 | __VIEWSTATE: $('input[name=__VIEWSTATE]').val(), 112 | __VIEWSTATEENCRYPTED: '', 113 | __VIEWSTATEGENERATOR: $('input[name=__VIEWSTATEGENERATOR]').val(), 114 | ctl00$ctl00$cph1$cph2$lvResults$DataPagerEx2$ctl00$ctl00: 10, 115 | ctl00$ctl00$cph1$ucItem$lvTitle$ctrl0$btLibraryList: 'Libraries' 116 | } 117 | 118 | const availabilityPage = await agent 119 | .post(itemPageUrl) 120 | .send(querystring.stringify(aspNetForm)) 121 | .set(headers) 122 | .timeout(20000) 123 | $ = cheerio.load(availabilityPage.text) 124 | 125 | const libs = {} 126 | $('#cph1_ucItem_lvTitle2_lvLocation_0_itemPlaceholderContainer_0 table tr') 127 | .slice(1) 128 | .each(function () { 129 | const name = $(this).find('td').eq(0).text().trim() 130 | const status = $(this).find('td').eq(1).text().trim() 131 | if (!libs[name]) { 132 | libs[name] = { available: 0, unavailable: 0 } 133 | } 134 | status !== 'Yes' ? libs[name].available++ : libs[name].unavailable++ 135 | }) 136 | for (const l in libs) 137 | responseHoldings.availability.push({ 138 | library: l, 139 | available: libs[l].available, 140 | unavailable: libs[l].unavailable 141 | }) 142 | } catch (e) { 143 | responseHoldings.exception = e 144 | } 145 | 146 | return common.endResponse(responseHoldings) 147 | } 148 | -------------------------------------------------------------------------------- /tests/spydus.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | const idx = require('.') 4 | 5 | const t = 300000 6 | 7 | test('E06000065', async () => await idx.runTest('North Yorkshire'), t) 8 | test('W06000006', async () => await idx.runTest('Wrecsam - Wrexham'), t) 9 | test('S12000033', async () => await idx.runTest('Aberdeen City'), t) 10 | test('S12000034', async () => await idx.runTest('Aberdeenshire'), t) 11 | test('W06000011', async () => await idx.runTest('Abertawe - Swansea'), t) 12 | test( 13 | 'W06000001', 14 | async () => await idx.runTest('Sir Ynys Mon - Isle of Anglesey'), 15 | t 16 | ) 17 | test('S12000041', async () => await idx.runTest('Angus'), t) 18 | test('S12000035', async () => await idx.runTest('Argyll and Bute'), t) 19 | test('E08000025', async () => await idx.runTest('Birmingham'), t) 20 | test('E06000009', async () => await idx.runTest('Blackpool'), t) 21 | test('E06000008', async () => await idx.runTest('Blackburn with Darwen'), t) 22 | test('W06000019', async () => await idx.runTest('Blaenau Gwent'), t) 23 | test('E08000001', async () => await idx.runTest('Bolton'), t) 24 | test( 25 | 'W06000013', 26 | async () => await idx.runTest('Pen-y-bont ar Ogwr - Bridgend'), 27 | t 28 | ) 29 | test('E06000043', async () => await idx.runTest('Brighton and Hove'), t) 30 | test('E10000002', async () => await idx.runTest('Buckinghamshire'), t) 31 | test('E08000002', async () => await idx.runTest('Bury'), t) 32 | test('E08000033', async () => await idx.runTest('Calderdale'), t) 33 | test('E10000003', async () => await idx.runTest('Cambridgeshire'), t) 34 | test('E09000007', async () => await idx.runTest('Camden'), t) 35 | test('W06000015', async () => await idx.runTest('Caerdydd - Cardiff'), t) 36 | test('W06000018', async () => await idx.runTest('Caerphilly'), t) 37 | test( 38 | 'W06000010', 39 | async () => await idx.runTest('Sir Gaerfyrddin - Carmarthenshire'), 40 | t 41 | ) 42 | test( 43 | 'W06000008', 44 | async () => await idx.runTest('Sir Ceredigion - Ceredigion'), 45 | t 46 | ) 47 | test('W06000003', async () => await idx.runTest('Conwy'), t) 48 | test( 49 | 'W06000004', 50 | async () => await idx.runTest('Sir Ddinbych - Denbighshire'), 51 | t 52 | ) 53 | test('S12000042', async () => await idx.runTest('Dundee City'), t) 54 | test('S12000010', async () => await idx.runTest('East Lothian'), t) 55 | test('S12000011', async () => await idx.runTest('East Renfrewshire'), t) 56 | test('E10000011', async () => await idx.runTest('East Sussex'), t) 57 | test('S12000014', async () => await idx.runTest('Falkirk'), t) 58 | test('S12000015', async () => await idx.runTest('Fife'), t) 59 | test('W06000005', async () => await idx.runTest('Sir y Fflint - Flintshire'), t) 60 | test('E10000013', async () => await idx.runTest('Gloucestershire'), t) 61 | test('W06000002', async () => await idx.runTest('Gwynedd'), t) 62 | test('E10000014', async () => await idx.runTest('Hampshire'), t) 63 | test('E06000001', async () => await idx.runTest('Hartlepool'), t) 64 | test('E10000015', async () => await idx.runTest('Hertfordshire'), t) 65 | test('S12000017', async () => await idx.runTest('Highland'), t) 66 | test('S12000018', async () => await idx.runTest('Inverclyde'), t) 67 | test('E06000046', async () => await idx.runTest('Isle of Wight'), t) 68 | test('E10000016', async () => await idx.runTest('Kent'), t) 69 | test('E08000003', async () => await idx.runTest('Manchester'), t) 70 | test('E06000035', async () => await idx.runTest('Medway'), t) 71 | test( 72 | 'W06000024', 73 | async () => await idx.runTest('Merthyr Tudful - Merthyr Tydfil'), 74 | t 75 | ) 76 | test('S12000019', async () => await idx.runTest('Midlothian'), t) 77 | test('E06000042', async () => await idx.runTest('Milton Keynes'), t) 78 | test('W06000021', async () => await idx.runTest('Sir Fynwy - Monmouthshire'), t) 79 | test('S12000020', async () => await idx.runTest('Moray'), t) 80 | test( 81 | 'W06000012', 82 | async () => await idx.runTest('Castell-nedd Port Talbot - Neath Port Talbot'), 83 | t 84 | ) 85 | test('W06000022', async () => await idx.runTest('Casnewydd - Newport'), t) 86 | test('E10000020', async () => await idx.runTest('Norfolk'), t) 87 | test('S12000021', async () => await idx.runTest('North Ayrshire'), t) 88 | test('S12000044', async () => await idx.runTest('North Lanarkshire'), t) 89 | 90 | test('E06000057', async () => await idx.runTest('Northumberland'), t) 91 | test('E08000004', async () => await idx.runTest('Oldham'), t) 92 | test('S12000023', async () => await idx.runTest('Orkney Islands'), t) 93 | test('S12000024', async () => await idx.runTest('Perth and Kinross'), t) 94 | test('E06000031', async () => await idx.runTest('Peterborough'), t) 95 | test( 96 | 'W06000009', 97 | async () => await idx.runTest('Sir Benfro - Pembrokeshire'), 98 | t 99 | ) 100 | test('E06000044', async () => await idx.runTest('Portsmouth'), t) 101 | test('W06000023', async () => await idx.runTest('Powys'), t) 102 | test('E06000038', async () => await idx.runTest('Reading'), t) 103 | test('W06000016', async () => await idx.runTest('Rhondda Cynon Taf'), t) 104 | test('E09000027', async () => await idx.runTest('Richmond upon Thames'), t) 105 | test('E08000005', async () => await idx.runTest('Rochdale'), t) 106 | test('E08000006', async () => await idx.runTest('Salford'), t) 107 | test('S12000026', async () => await idx.runTest('Scottish Borders'), t) 108 | test('S12000027', async () => await idx.runTest('Shetland Islands'), t) 109 | test('E06000039', async () => await idx.runTest('Slough'), t) 110 | test('E08000029', async () => await idx.runTest('Solihull'), t) 111 | test('S12000029', async () => await idx.runTest('South Lanarkshire'), t) 112 | test('E06000045', async () => await idx.runTest('Southampton'), t) 113 | test('E06000033', async () => await idx.runTest('Southend on Sea'), t) 114 | test('E09000028', async () => await idx.runTest('Southwark'), t) 115 | test('S12000030', async () => await idx.runTest('Stirling'), t) 116 | test('E08000007', async () => await idx.runTest('Stockport'), t) 117 | test('E06000004', async () => await idx.runTest('Stockton on Tees'), t) 118 | test('E10000029', async () => await idx.runTest('Suffolk'), t) 119 | test('E06000030', async () => await idx.runTest('Swindon'), t) 120 | test('E08000008', async () => await idx.runTest('Tameside'), t) 121 | test('W06000020', async () => await idx.runTest('Tor-faen - Torfaen'), t) 122 | test('E08000009', async () => await idx.runTest('Trafford'), t) 123 | test( 124 | 'W06000014', 125 | async () => await idx.runTest('Bro Morgannwg - the Vale of Glamorgan'), 126 | t 127 | ) 128 | test('E06000037', async () => await idx.runTest('West Berkshire'), t) 129 | test('E08000010', async () => await idx.runTest('Wigan'), t) 130 | test('E06000040', async () => await idx.runTest('Windsor & Maidenhead'), t) 131 | test('E06000041', async () => await idx.runTest('Wokingham'), t) 132 | -------------------------------------------------------------------------------- /connectors/iguana.js: -------------------------------------------------------------------------------- 1 | const request = require('superagent') 2 | const xml2js = require('xml2js') 3 | 4 | const common = require('../connectors/common') 5 | 6 | const ITEM_SEARCH = 7 | 'fu=BibSearch&RequestType=ResultSet_DisplayList&NumberToRetrieve=10&StartValue=1&SearchTechnique=Find&Language=eng&Profile=Iguana&ExportByTemplate=Brief&TemplateId=Iguana_Brief&FacetedSearch=Yes&MetaBorrower=&Cluster=0&Namespace=0&BestMatch=99&ASRProfile=&Sort=Relevancy&SortDirection=1&WithoutRestrictions=Yes&Associations=Also&Application=Bib&Database=[DB]&Index=Keywords&Request=[ISBN]&SessionCMS=&CspSessionId=[SID]&SearchMode=simple&SIDTKN=[SID]' 8 | const FACET_SEARCH = 9 | 'FacetedSearch=[RESULTID]&FacetsFound=&fu=BibSearch&SIDTKN=[SID]' 10 | const HEADER = { 11 | 'Content-Type': 'application/x-www-form-urlencoded', 12 | 'X-Requested-With': 'XMLHttpRequest' 13 | } 14 | const HOME = 'www.main.cls' 15 | 16 | /** 17 | * Gets the object representing the service 18 | * @param {object} service 19 | */ 20 | exports.getService = service => { 21 | const serviceData = common.getService(service) 22 | serviceData.url = service.Url + HOME 23 | return serviceData 24 | } 25 | 26 | /** 27 | * Gets the libraries in the service based upon possible search and filters within the library catalogue 28 | * @param {object} service 29 | */ 30 | exports.getLibraries = async function (service) { 31 | const responseLibraries = common.initialiseGetLibrariesResponse(service) 32 | 33 | try { 34 | const agent = request.agent() 35 | 36 | const homePageRequest = await agent.get(service.Url + HOME) 37 | const sessionCookie = homePageRequest.headers['set-cookie'][0] 38 | const iguanaCookieIndex = sessionCookie.indexOf('iguana-=') 39 | const sid = sessionCookie.substring( 40 | iguanaCookieIndex + 20, 41 | iguanaCookieIndex + 30 42 | ) 43 | 44 | const body = ITEM_SEARCH.replace('[ISBN]', 'harry') 45 | .replace('Index=Isbn', 'Index=Keywords') 46 | .replace('[DB]', service.Database) 47 | .replace('[TID]', 'Iguana_Brief') 48 | .replace(/\[SID\]/g, sid) 49 | 50 | const searchUrl = service.Url + 'Proxy.SearchRequest.cls' 51 | const searchPageRequest = await agent 52 | .post(searchUrl) 53 | .send(body) 54 | .set({ ...HEADER, Referer: service.Url + HOME }) 55 | .timeout(20000) 56 | .buffer() 57 | const searchJs = await xml2js.parseStringPromise(searchPageRequest.text) 58 | 59 | if (service.Faceted) { 60 | const resultId = searchJs.searchRetrieveResponse.resultSetId[0] 61 | 62 | const facetRequest = await agent 63 | .post(service.Url + 'Proxy.SearchRequest.cls') 64 | .send( 65 | FACET_SEARCH.replace('[RESULTID]', resultId).replace(/\[SID\]/g, sid) 66 | ) 67 | .set({ ...HEADER, Referer: service.Url + HOME }) 68 | .timeout(20000) 69 | .buffer() 70 | const facetJs = await xml2js.parseStringPromise(facetRequest.text) 71 | const facets = facetJs.VubisFacetedSearchResponse.Facets[0].Facet 72 | 73 | if (facets) { 74 | facets.forEach(facet => { 75 | if (facet.FacetWording[0] === service.LibraryFacet) { 76 | facet.FacetEntry.forEach(location => 77 | responseLibraries.libraries.push(location.Display[0]) 78 | ) 79 | } 80 | }) 81 | } 82 | } else { 83 | if ( 84 | searchJs && 85 | searchJs.searchRetrieveResponse && 86 | searchJs.searchRetrieveResponse.records 87 | ) { 88 | searchJs.searchRetrieveResponse.records[0].record.forEach(function ( 89 | record 90 | ) { 91 | const recData = record.recordData 92 | if ( 93 | recData && 94 | recData[0] && 95 | recData[0].BibDocument && 96 | recData[0].BibDocument[0] && 97 | recData[0].BibDocument[0].HoldingsSummary && 98 | recData[0].BibDocument[0].HoldingsSummary[0] 99 | ) { 100 | recData[0].BibDocument[0].HoldingsSummary[0].ShelfmarkData.forEach( 101 | item => { 102 | const lib = item.Shelfmark[0].split(' : ')[0] 103 | if (responseLibraries.libraries.indexOf(lib) === -1) 104 | responseLibraries.libraries.push(lib) 105 | } 106 | ) 107 | } 108 | }) 109 | } 110 | } 111 | } catch (e) { 112 | responseLibraries.exception = e 113 | } 114 | 115 | return common.endResponse(responseLibraries) 116 | } 117 | 118 | /** 119 | * Retrieves the availability summary of an ISBN by library 120 | * @param {string} isbn 121 | * @param {object} service 122 | */ 123 | exports.searchByISBN = async function (isbn, service) { 124 | const responseHoldings = common.initialiseSearchByISBNResponse(service) 125 | 126 | try { 127 | const agent = request.agent() 128 | 129 | const homePageRequest = await agent.get(service.Url + HOME) 130 | const sessionCookie = homePageRequest.headers['set-cookie'][0] 131 | const iguanaCookieIndex = sessionCookie.indexOf('iguana-=') 132 | const sid = sessionCookie.substring( 133 | iguanaCookieIndex + 20, 134 | iguanaCookieIndex + 30 135 | ) 136 | const searchPageRequest = await agent 137 | .post(service.Url + 'Proxy.SearchRequest.cls') 138 | .set({ ...HEADER, Referer: service.Url + HOME }) 139 | .send( 140 | ITEM_SEARCH.replace('[ISBN]', isbn) 141 | .replace('[DB]', service.Database) 142 | .replace('[TID]', 'Iguana_Brief') 143 | .replace(/\[SID\]/g, sid) 144 | ) 145 | .timeout(20000) 146 | .buffer() 147 | const searchJs = await xml2js.parseStringPromise(searchPageRequest.text) 148 | 149 | let record = null 150 | if ( 151 | searchJs?.searchRetrieveResponse && 152 | !searchJs.searchRetrieveResponse.bestMatch && 153 | searchJs.searchRetrieveResponse.records && 154 | searchJs.searchRetrieveResponse.records[0].record 155 | ) 156 | record = searchJs.searchRetrieveResponse.records[0]?.record[0] 157 | 158 | if ( 159 | record?.recordData && 160 | record.recordData[0] && 161 | record.recordData[0].BibDocument[0] 162 | ) { 163 | responseHoldings.id = record.recordData[0].BibDocument[0].Id[0] 164 | } 165 | 166 | if ( 167 | record?.recordData && 168 | record.recordData[0] && 169 | record.recordData[0].BibDocument[0] && 170 | record.recordData[0].BibDocument[0].HoldingsSummary 171 | ) { 172 | record.recordData[0].BibDocument[0].HoldingsSummary[0].ShelfmarkData.forEach( 173 | function (item) { 174 | if (item.Shelfmark && item.Available) { 175 | const lib = item.Shelfmark[0].split(' : ')[0] 176 | responseHoldings.availability.push({ 177 | library: lib, 178 | available: item.Available ? parseInt(item.Available[0]) : 0, 179 | unavailable: item.Available[0] === '0' ? 1 : 0 180 | }) 181 | } 182 | } 183 | ) 184 | } 185 | } catch (e) { 186 | responseHoldings.exception = e 187 | } 188 | 189 | return common.endResponse(responseHoldings) 190 | } 191 | -------------------------------------------------------------------------------- /connectors/enterprise.js: -------------------------------------------------------------------------------- 1 | const cheerio = require('cheerio') 2 | const request = require('superagent') 3 | const UserAgent = require('user-agents') 4 | 5 | const common = require('../connectors/common') 6 | 7 | const SEARCH_URL = 'search/results?qu=' 8 | const ITEM_URL = 'search/detailnonmodal/ent:[ILS]/one' 9 | const HEADER = { 10 | 'User-Agent': new UserAgent().toString(), 11 | } 12 | const HEADER_POST = { 'X-Requested-With': 'XMLHttpRequest' } 13 | 14 | /** 15 | * Gets the object representing the service 16 | * @param {object} service 17 | */ 18 | exports.getService = service => common.getService(service) 19 | 20 | /** 21 | * Gets the libraries in the service based upon possible search and filters within the library catalogue 22 | * @param {object} service 23 | */ 24 | exports.getLibraries = async function (service) { 25 | const agent = request.agent() 26 | const responseLibraries = common.initialiseGetLibrariesResponse(service) 27 | 28 | let $ = null 29 | try { 30 | const advancedPage = await agent 31 | .get(service.Url + 'search/advanced') 32 | .timeout(30000) 33 | $ = cheerio.load(advancedPage.text) 34 | } catch (e) { 35 | responseLibraries.exception = e 36 | return common.endResponse(responseLibraries) 37 | } 38 | 39 | $('#libraryDropDown option').each((idx, lib) => { 40 | const name = $(lib).text().trim() 41 | if ( 42 | common.isLibrary(name) && 43 | ((service.LibraryNameFilter && 44 | name.indexOf(service.LibraryNameFilter) !== -1) || 45 | !service.LibraryNameFilter) 46 | ) { 47 | responseLibraries.libraries.push(name) 48 | } 49 | }) 50 | return common.endResponse(responseLibraries) 51 | } 52 | 53 | /** 54 | * Retrieves the availability summary of an ISBN by library 55 | * @param {string} isbn 56 | * @param {object} service 57 | */ 58 | exports.searchByISBN = async function (isbn, service) { 59 | const agent = request.agent() 60 | const responseHoldings = common.initialiseSearchByISBNResponse(service) 61 | responseHoldings.url = service.Url + SEARCH_URL + isbn 62 | let itemPage = '' 63 | 64 | let itemId = null 65 | let $ = null 66 | let deepLinkPageUrl = null 67 | try { 68 | // We could also use RSS https://wales.ent.sirsidynix.net.uk/client/rss/hitlist/ynysmon_en/qu=9780747538493 69 | const deepLinkPageRequest = await agent 70 | .get(responseHoldings.url) 71 | .set(HEADER) 72 | .timeout(30000) 73 | 74 | if (deepLinkPageRequest.redirects.length > 0) { 75 | const url = deepLinkPageRequest.redirects.find(x => x.indexOf('ent:') > 0) 76 | if (url) { 77 | deepLinkPageUrl = url 78 | } else { 79 | deepLinkPageUrl = responseHoldings.url 80 | } 81 | } else { 82 | deepLinkPageUrl = responseHoldings.url 83 | } 84 | 85 | if (deepLinkPageUrl.indexOf('ent:') > 0) { 86 | itemId = 87 | deepLinkPageUrl.substring( 88 | deepLinkPageUrl.lastIndexOf('ent:') + 4, 89 | deepLinkPageUrl.lastIndexOf('/one') 90 | ) || '' 91 | responseHoldings.id = itemId 92 | } 93 | 94 | $ = cheerio.load(deepLinkPageRequest.text) 95 | itemPage = deepLinkPageRequest.text 96 | 97 | if (deepLinkPageUrl.lastIndexOf('ent:') === -1) { 98 | // In this situation we're probably still on the search page (there may be duplicate results). 99 | const items = $('input.results_chkbox.DISCOVERY_ALL') 100 | 101 | for (const item of items) { 102 | itemId = item.attribs.value 103 | itemId = itemId.substring(itemId.lastIndexOf('ent:') + 4) 104 | itemId = itemId.split('/').join('$002f') 105 | responseHoldings.id = itemId 106 | 107 | if (itemId === '') return common.endResponse(responseHoldings) 108 | 109 | const itemPageUrl = service.Url + ITEM_URL.replace('[ILS]', itemId) 110 | const itemPageRequest = await agent.get(itemPageUrl).timeout(30000) 111 | itemPage = itemPageRequest.text 112 | 113 | responseHoldings.availability = await processItemPage( 114 | agent, 115 | itemId, 116 | itemPage, 117 | service 118 | ) 119 | 120 | if (responseHoldings.availability.length > 0) { 121 | break 122 | } 123 | } 124 | } else { 125 | responseHoldings.availability = await processItemPage( 126 | agent, 127 | itemId, 128 | itemPage, 129 | service 130 | ) 131 | } 132 | } catch (e) { 133 | responseHoldings.exception = e 134 | } 135 | 136 | return common.endResponse(responseHoldings) 137 | } 138 | 139 | const processItemPage = async (agent, itemId, itemPage, service) => { 140 | let availabilityJson = null 141 | const availability = [] 142 | 143 | // Get CSRF token, if available 144 | const csrfMatches = /__sdcsrf\s+=\s+"([a-f0-9\-]+)"/gm.exec(itemPage) 145 | let csrf = null 146 | if (csrfMatches && csrfMatches[1]) { 147 | csrf = csrfMatches[1] 148 | } 149 | 150 | let $ = cheerio.load(itemPage) 151 | 152 | // Availability information may already be part of the page. 153 | const matches = /parseDetailAvailabilityJSON\(([\s\S]*?)\)/.exec(itemPage) 154 | if (matches && matches[1] && common.isJsonString(matches[1])) { 155 | availabilityJson = JSON.parse(matches[1]) 156 | } 157 | 158 | if (availabilityJson === null && service.AvailabilityUrl) { 159 | // e.g. /search/detailnonmodal.detail.detailavailabilityaccordions:lookuptitleinfo/ent:$002f$002fSD_ILS$002f0$002fSD_ILS:548433/ILS/0/true/true?qu=9780747538493&d=ent%3A%2F%2FSD_ILS%2F0%2FSD_ILS%3A548433%7E%7E0&ps=300 160 | const availabilityUrl = 161 | service.Url + 162 | service.AvailabilityUrl.replace( 163 | '[ITEMID]', 164 | itemId.split('/').join('$002f') 165 | ) 166 | 167 | const availabilityPageRequest = await agent 168 | .post(availabilityUrl) 169 | .set(HEADER_POST) 170 | .set({ sdcsrf: csrf }) 171 | .timeout(30000) 172 | const availabilityResponse = availabilityPageRequest.body 173 | if (availabilityResponse.ids || availabilityResponse.childRecords) { 174 | availabilityJson = availabilityResponse 175 | } 176 | } 177 | 178 | if (availabilityJson?.childRecords) { 179 | const libs = {} 180 | $(availabilityJson.childRecords).each(function (i, c) { 181 | const name = c.LIBRARY 182 | const status = c.SD_ITEM_STATUS 183 | if (!libs[name]) libs[name] = { available: 0, unavailable: 0 } 184 | service.Available.indexOf(status) > 0 185 | ? libs[name].available++ 186 | : libs[name].unavailable++ 187 | }) 188 | for (var lib in libs) { 189 | availability.push({ 190 | library: lib, 191 | available: libs[lib].available, 192 | unavailable: libs[lib].unavailable 193 | }) 194 | } 195 | return availability 196 | } 197 | 198 | if (availabilityJson?.ids) { 199 | $ = cheerio.load(itemPage) 200 | const libs = {} 201 | $('.detailItemsTableRow').each(function (index, elem) { 202 | const name = $(this).find('td').eq(0).text().trim() 203 | const bc = $(this) 204 | .find('td div') 205 | .attr('id') 206 | .replace('availabilityDiv', '') 207 | if ( 208 | bc && 209 | availabilityJson.ids && 210 | availabilityJson.ids.length > 0 && 211 | availabilityJson.strings && 212 | availabilityJson.ids.indexOf(bc) !== -1 213 | ) { 214 | const status = 215 | availabilityJson.strings[availabilityJson.ids.indexOf(bc)].trim() 216 | if (!libs[name]) libs[name] = { available: 0, unavailable: 0 } 217 | service.Available.indexOf(status) > 0 218 | ? libs[name].available++ 219 | : libs[name].unavailable++ 220 | } 221 | }) 222 | for (const l in libs) { 223 | availability.push({ 224 | library: l, 225 | available: libs[l].available, 226 | unavailable: libs[l].unavailable 227 | }) 228 | } 229 | return availability 230 | } 231 | 232 | if (service.TitleDetailUrl) { 233 | const titleUrl = 234 | service.Url + 235 | service.TitleDetailUrl.replace( 236 | '[ITEMID]', 237 | itemId.split('/').join('$002f') 238 | ) 239 | 240 | const titleDetailRequest = await agent 241 | .post(titleUrl) 242 | .set(HEADER_POST) 243 | .timeout(30000) 244 | const titles = titleDetailRequest.body 245 | const libs = {} 246 | $(titles.childRecords).each(function (i, c) { 247 | const name = c.LIBRARY 248 | const status = c.SD_ITEM_STATUS 249 | if (!libs[name]) libs[name] = { available: 0, unavailable: 0 } 250 | service.Available.indexOf(status) > 0 251 | ? libs[name].available++ 252 | : libs[name].unavailable++ 253 | }) 254 | for (var lib in libs) { 255 | availability.push({ 256 | library: lib, 257 | available: libs[lib].available, 258 | unavailable: libs[lib].unavailable 259 | }) 260 | } 261 | return availability 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /connectors/aspen.js: -------------------------------------------------------------------------------- 1 | const cheerio = require('cheerio') 2 | const request = require('superagent') 3 | 4 | const common = require('./common') 5 | 6 | const UserAgent = require('user-agents') 7 | 8 | const HEADER = { 9 | 'User-Agent': new UserAgent({ deviceCategory: 'mobile' }).toString(), 10 | } 11 | 12 | const ADVANCED_SEARCH_URL = 13 | 'Union/Search?view=list&lookfor=&searchIndex=advanced&searchSource=local' 14 | 15 | const SEARCH_RESULTS_URL = 16 | 'Union/Search?view=list&lookfor=[ISBN]&searchIndex=Keyword&searchSource=local' 17 | 18 | /** 19 | * Gets the object representing the service 20 | * @param {object} service 21 | */ 22 | exports.getService = service => common.getService(service) 23 | 24 | /** 25 | * Gets the libraries in the service based upon possible search and filters within the library catalogue 26 | * @param {object} service 27 | */ 28 | exports.getLibraries = async function (service) { 29 | const responseLibraries = common.initialiseGetLibrariesResponse(service) 30 | 31 | try { 32 | const agent = request.agent() 33 | const url = `${service.Url}${ADVANCED_SEARCH_URL}` 34 | 35 | const res = await agent.get(url).set(HEADER) 36 | 37 | const $ = cheerio.load(res.text) 38 | const libraries = [] 39 | 40 | // The library dropdown elements all include a value that contains 'owning_location:' 41 | $('option').each((i, el) => { 42 | if (!$(el).val().includes('owning_location:')) return 43 | 44 | const library = $(el).text().trim() 45 | 46 | // Add the library to the array 47 | libraries.push(library) 48 | }) 49 | 50 | responseLibraries.libraries = libraries 51 | } catch (e) { 52 | responseLibraries.exception = e 53 | } 54 | 55 | return common.endResponse(responseLibraries) 56 | } 57 | 58 | /** 59 | * Retrieves the availability summary of an ISBN by library 60 | * @param {string} isbn 61 | * @param {object} service 62 | */ 63 | exports.searchByISBN = async function (isbn, service) { 64 | const responseHoldings = common.initialiseSearchByISBNResponse(service) 65 | 66 | try { 67 | const agent = request.agent() 68 | const url = `${service.Url}${SEARCH_RESULTS_URL.replace('[ISBN]', isbn)}` 69 | 70 | const res = await agent.get(url).set(HEADER) 71 | 72 | let $ = cheerio.load(res.text) 73 | 74 | // In the results list get the first item 75 | const firstItem = $('.resultsList').find('a').first() 76 | 77 | // Get the URL for the first item 78 | const itemId = firstItem.attr('id').replace('record', '') 79 | responseHoldings.id = itemId 80 | 81 | const itemUrl = `${service.Url}GroupedWork/${itemId}` 82 | responseHoldings.url = itemUrl 83 | 84 | // We should be able to get a list of available copies from 85 | // https://libraries.rbkc.gov.uk/GroupedWork/ad09c758-1613-5091-c11c-c35c85aec1f5-eng/AJAX?method=getCopyDetails&format=Book&recordId=ad09c758-1613-5091-c11c-c35c85aec1f5-eng 86 | const copiesUrl = `${service.Url}GroupedWork/${itemId}/AJAX?method=getCopyDetails&format=Book&recordId=${itemId}` 87 | const copiesRes = await agent.get(copiesUrl).set(HEADER) 88 | 89 | // That returns a JSON object with HTML 90 | // e.g. { "title": "Where is it?", "modalBody": "
Available Copies<\/th>Location<\/th>Call #<\/th><\/tr><\/thead>
1 of 10 <\/i><\/td>Kensington Central Library - F9 - Children's Library<\/td>STORIES<\/td><\/tr>
1 of 1 <\/i><\/td>Kensington Central Library - F9 - Children's Library<\/td>Stories<\/td><\/tr>
1 of 3 <\/i><\/td>Kensington Central Library - FAN - Adult lending<\/td>FAN<\/td><\/tr>
1 of 1 <\/i><\/td>Brompton Library - F9 - Children's Library<\/td>AUTO<\/td><\/tr>
0 of 3<\/td>Brompton Library - F9 - Children's Library<\/td>STORIES<\/td><\/tr>
0 of 3<\/td>Chelsea Library - F9 - Children's Library<\/td>STORIES<\/td><\/tr>
1 of 1 <\/i><\/td>Chelsea Library - F9 - Children's Library<\/td>STORIES - OVERSIZE<\/td><\/tr>
0 of 1<\/td>Chelsea Library - F9 - Children's Library<\/td>Stories<\/td><\/tr>
2 of 4 <\/i><\/td>Kensal Library - F9 - Children's Library<\/td>STORIES<\/td><\/tr>
1 of 10 <\/i><\/td>North Kensington Library - F9 - Children's Library<\/td>STORIES<\/td><\/tr>
1 of 1 <\/i><\/td>Charing Cross Library - F7 - Children's Library<\/td>Stories<\/td><\/tr>
3 of 4 <\/i><\/td>Charing Cross Library - F9 - Children's Library<\/td>Stories<\/td><\/tr>
0 of 2<\/td>Church Street Library - F7 - Children's Library<\/td>Stories<\/td><\/tr>
0 of 9<\/td>Church Street Library - F9 - Children's Library<\/td>Stories<\/td><\/tr>
1 of 3 <\/i><\/td>Maida Vale Library - F7 - Children's Library<\/td>Stories<\/td><\/tr>
0 of 1<\/td>Maida Vale Library - F9 - Children's Library<\/td>STORIES<\/td><\/tr>
1 of 11 <\/i><\/td>Maida Vale Library - F9 - Children's Library<\/td>Stories<\/td><\/tr>
0 of 1<\/td>Marylebone Library - F7 - Children's Library<\/td>STORIES<\/td><\/tr>
1 of 4 <\/i><\/td>Marylebone Library - F9 - Children's Library<\/td>Stories<\/td><\/tr>
0 of 1<\/td>Marylebone Library - FAN - Adult lending<\/td>STORIES<\/td><\/tr>
1 of 3 <\/i><\/td>Mayfair Library - F9 - Children's Library<\/td>Stories<\/td><\/tr>
0 of 11<\/td>Paddington Children's Library - F9 - Children's Library<\/td>Stories<\/td><\/tr>
0 of 1<\/td>Paddington Library - F9 - Children's Library<\/td>Stories<\/td><\/tr>
2 of 2 <\/i><\/td>Pimlico Library - F7 - Children's Library<\/td>Stories<\/td><\/tr>
1 of 1 <\/i><\/td>Pimlico Library - F9 - Children's Library<\/td>AUTO<\/td><\/tr>
0 of 6<\/td>Pimlico Library - F9 - Children's Library<\/td>STORIES<\/td><\/tr>
0 of 3<\/td>Pimlico Library - F9 - Children's Library<\/td>Stories<\/td><\/tr>
1 of 1 <\/i><\/td>Pimlico Library - FAN - Adult lending<\/td>FICTION SF<\/td><\/tr>
0 of 1<\/td>Pimlico Library - FICCD - Children's Library<\/td>AUTO<\/td><\/tr>
0 of 2<\/td>Queen's Park Library - F7 - Children's Library<\/td>Stories<\/td><\/tr>
1 of 7 <\/i><\/td>Queen's Park Library - F9 - Children's Library<\/td>Stories<\/td><\/tr>
1 of 1 <\/i><\/td>Queen's Park Library - SCF - Adult lending<\/td>FICTION SF<\/td><\/tr>
0 of 1<\/td>St John's Wood Library - F7 - Children's Library<\/td>STORIES<\/td><\/tr>
1 of 2 <\/i><\/td>St John's Wood Library - F7 - Children's Library<\/td>Stories<\/td><\/tr>
1 of 1 <\/i><\/td>St John's Wood Library - F9 - Children's Library<\/td>STORIES<\/td><\/tr>
1 of 1 <\/i><\/td>St John's Wood Library - FAN - Adult lending<\/td>FICTION SF<\/td><\/tr>
0 of 1<\/td>St John's Wood Library - FAN - Children's Library<\/td>Stories<\/td><\/tr>
0 of 1<\/td>Victoria Library - F7 - ZCHILDRENS<\/td>Stories<\/td><\/tr>
0 of 3<\/td>Victoria Library - F9 - Children's Library<\/td>Stories<\/td><\/tr>
0 of 2<\/td>Victoria Library - F9 - ZCHILDRENS<\/td>Stories<\/td><\/tr>
1 of 1 <\/i><\/td>Victoria Library - SCF - Adult lending<\/td>FICTION SF<\/td><\/tr><\/tbody><\/table><\/div>"} 91 | 92 | const copiesHtml = JSON.parse(copiesRes.text).modalBody 93 | $ = cheerio.load(copiesHtml) 94 | 95 | // The HTML is a table with the headers of Available Copies, Location and Call # 96 | // Available copies is something like 1 of 10 97 | // Location is the library name 98 | $('table tbody tr').each((i, tr) => { 99 | const copiesText = $(tr).find('td').eq(0).text().trim() 100 | const availableText = copiesText.split(' of ')[0] 101 | const totalText = copiesText.split(' of ')[1] 102 | const total = parseInt(totalText) 103 | const available = parseInt(availableText) 104 | const unavailable = total - available 105 | const library = $(tr).find('td').eq(1).text().trim() 106 | responseHoldings.availability.push({ library, available, unavailable }) 107 | }) 108 | } catch (e) { 109 | responseHoldings.exception = e 110 | } 111 | return common.endResponse(responseHoldings) 112 | } 113 | -------------------------------------------------------------------------------- /connectors/arena.v7.js: -------------------------------------------------------------------------------- 1 | // HTTP Header: 2 | // Liferay-Portal: Liferay Community Edition Portal 7.0.6 GA7 (Wilberforce / Build 7006 / April 17, 2018) 3 | 4 | const cheerio = require('cheerio') 5 | const querystring = require('querystring') 6 | const request = require('superagent') 7 | const xml2js = require('xml2js') 8 | 9 | const common = require('./common') 10 | 11 | const LIBRARIES_URL_PORTLET = 12 | '?p_p_id=extendedSearch_WAR_arenaportlet&p_p_lifecycle=2&p_p_state=normal&p_p_mode=view&p_p_resource_id=%2FextendedSearch%2F%3Fwicket%3Ainterface%3D%3A1%3AextendedSearchPanel%3AextendedSearchForm%3AorganisationHierarchyPanel%3AorganisationContainer%3AorganisationChoice%3A%3AIBehaviorListener%3A0%3A&p_p_cacheability=cacheLevelPage&random=0.09116463849406953' 13 | const SEARCH_URL_PORTLET = 14 | 'search?p_p_id=searchResult_WAR_arenaportlet&p_p_lifecycle=1&p_p_state=normal&p_r_p_arena_urn:arena_facet_queries=&p_r_p_arena_urn:arena_search_type=solr&p_r_p_arena_urn:arena_search_query=[BOOKQUERY]' 15 | const ITEM_URL_PORTLET = 16 | 'results?p_p_id=crDetailWicket_WAR_arenaportlet&p_p_lifecycle=1&p_p_state=normal&p_r_p_arena_urn:arena_search_item_id=[ITEMID]&p_r_p_arena_urn:arena_facet_queries=&p_r_p_arena_urn:arena_agency_name=[ARENANAME]&p_r_p_arena_urn:arena_search_item_no=0&p_r_p_arena_urn:arena_search_type=solr' 17 | const HOLDINGS_URL_PORTLET = 18 | 'results?p_p_id=crDetailWicket_WAR_arenaportlet&p_p_lifecycle=2&p_p_state=normal&p_p_mode=view&p_p_resource_id=/crDetailWicket/?wicket:interface=:0:recordPanel:holdingsPanel::IBehaviorListener:0:&p_p_cacheability=cacheLevelPage&p_p_col_id=column-2&p_p_col_pos=1&p_p_col_count=3' 19 | const HOLDINGSDETAIL_URL_PORTLET = 20 | 'results?p_p_id=crDetailWicket_WAR_arenaportlet&p_p_lifecycle=2&p_p_state=normal&p_p_mode=view&p_p_resource_id=[RESOURCEID]&p_p_cacheability=' 21 | 22 | /** 23 | * Gets the object representing the service 24 | * @param {object} service 25 | */ 26 | exports.getService = service => common.getService(service) 27 | 28 | /** 29 | * Gets the libraries in the service based upon possible search and filters within the library catalogue 30 | * @param {object} service 31 | */ 32 | exports.getLibraries = async function (service) { 33 | const responseLibraries = common.initialiseGetLibrariesResponse(service) 34 | 35 | try { 36 | const agent = request.agent() 37 | let $ = null 38 | 39 | if (service.SignupUrl) { 40 | // This service needs to be loaded using the signup page rather 41 | // than the advanced search page. 42 | const signupResponse = await agent.get(service.SignupUrl) 43 | $ = cheerio.load(signupResponse.text) 44 | 45 | if ($('select[name="branches-div:choiceBranch"] option').length > 1) { 46 | $('select[name="branches-div:choiceBranch"] option').each(function () { 47 | if (common.isLibrary($(this).text())) 48 | responseLibraries.libraries.push($(this).text()) 49 | }) 50 | return common.endResponse(responseLibraries) 51 | } 52 | } 53 | 54 | // Get the advanced search page 55 | const advancedSearchResponse = await agent 56 | .get(service.Url + service.AdvancedUrl) 57 | .set({ Connection: 'keep-alive' }) 58 | .timeout(20000) 59 | 60 | // The advanced search page may have libraries listed on it 61 | $ = cheerio.load(advancedSearchResponse.text) 62 | if ($('.arena-extended-search-branch-choice option').length > 1) { 63 | $('.arena-extended-search-branch-choice option').each(function () { 64 | if (common.isLibrary($(this).text())) 65 | responseLibraries.libraries.push($(this).text()) 66 | }) 67 | return common.endResponse(responseLibraries) 68 | } 69 | 70 | // If not we'll need to call a portlet to get the data 71 | const focusedElementId = $( 72 | '.arena-extended-search-organisation-choice' 73 | ).attr('id') 74 | const headers = { 75 | Accept: 'text/xml', 76 | 'Content-Type': 'application/x-www-form-urlencoded', 77 | 'Wicket-Ajax': true, 78 | 'Wicket-Focusedelementid': focusedElementId 79 | } 80 | const branchChoiceQueries = { 81 | p_p_id: 'extendedSearch_WAR_arenaportlet', 82 | p_p_lifecycle: '2', 83 | p_p_state: 'normal', 84 | p_p_mode: 'view', 85 | p_p_resource_id: 86 | '/extendedSearch/?wicket:interface=:0:extendedSearchPanel:extendedSearchForm:organisationHierarchyPanel:organisationContainer:organisationChoice::IBehaviorListener:0:', 87 | p_p_cacheability: 'cacheLevelPage', 88 | random: '0.1234567890123456' 89 | } 90 | // The url needs to be built from the branch choice queries above 91 | let url = 92 | service.Url + 93 | service.AdvancedUrl + 94 | '?' + 95 | querystring.stringify(branchChoiceQueries) 96 | const responseHeaderRequest = await agent 97 | .post(url) 98 | .send( 99 | querystring.stringify({ 100 | 'organisationHierarchyPanel:organisationContainer:organisationChoice': 101 | service.OrganisationId 102 | }) 103 | ) 104 | .set(headers) 105 | const js = await xml2js.parseStringPromise(responseHeaderRequest.text) 106 | 107 | // Parse the results of the request 108 | if (js && js !== 'Undeployed' && js['ajax-response']?.component) { 109 | $ = cheerio.load(js['ajax-response'].component[0]._) 110 | $('option').each(function () { 111 | if (common.isLibrary($(this).text())) 112 | responseLibraries.libraries.push($(this).text()) 113 | }) 114 | } 115 | } catch (e) { 116 | responseLibraries.exception = e 117 | } 118 | 119 | return common.endResponse(responseLibraries) 120 | } 121 | 122 | /** 123 | * Retrieves the availability summary of an ISBN by library 124 | * @param {string} isbn 125 | * @param {object} service 126 | */ 127 | exports.searchByISBN = async function (isbn, service) { 128 | const responseHoldings = common.initialiseSearchByISBNResponse(service) 129 | 130 | try { 131 | const agent = request.agent() 132 | 133 | let bookQuery = 134 | service.SearchType !== 'Keyword' ? 'number_index:' + isbn : isbn 135 | 136 | if (service.OrganisationId && service.MultipleOrganisations) { 137 | bookQuery = 138 | 'organisationId_index:' + service.OrganisationId + '+AND+' + bookQuery 139 | } 140 | 141 | const searchUrl = SEARCH_URL_PORTLET.replace('[BOOKQUERY]', bookQuery) 142 | responseHoldings.url = service.Url + searchUrl 143 | 144 | const searchResponse = await agent.get(responseHoldings.url).timeout(20000) 145 | 146 | // No item found 147 | if ( 148 | !searchResponse || 149 | !searchResponse.text || 150 | (searchResponse.text && 151 | searchResponse.text.lastIndexOf('search_item_id') === -1) 152 | ) 153 | return common.endResponse(responseHoldings) 154 | 155 | // Call to the item page 156 | const pageText = searchResponse.text 157 | .replace(/\\x3d/g, '=') 158 | .replace(/\\x26/g, '&') 159 | let itemId = pageText.substring( 160 | pageText.lastIndexOf('search_item_id=') + 15 161 | ) 162 | itemId = itemId.substring(0, itemId.indexOf('&')) 163 | responseHoldings.id = itemId 164 | 165 | const itemDetailsUrl = ITEM_URL_PORTLET.replace( 166 | '[ARENANAME]', 167 | service.ArenaName 168 | ).replace('[ITEMID]', itemId) 169 | const itemUrl = service.Url + itemDetailsUrl 170 | 171 | const itemPageResponse = await agent 172 | .get(itemUrl) 173 | .set({ Connection: 'keep-alive' }) 174 | .timeout(20000) 175 | let $ = cheerio.load(itemPageResponse.text) 176 | 177 | if ($('.arena-availability-viewbranch').length > 0) { 178 | // If the item holdings are available immediately on the page 179 | $('.arena-availability-viewbranch').each(function () { 180 | const libName = $(this).find('.arena-branch-name span').text() 181 | const totalAvailable = $(this) 182 | .find('.arena-availability-info span') 183 | .eq(0) 184 | .text() 185 | .replace('Total ', '') 186 | const checkedOut = $(this) 187 | .find('.arena-availability-info span') 188 | .eq(1) 189 | .text() 190 | .replace('On loan ', '') 191 | const av = 192 | (totalAvailable ? parseInt(totalAvailable) : 0) - 193 | (checkedOut ? parseInt(checkedOut) : 0) 194 | const nav = checkedOut !== '' ? parseInt(checkedOut) : 0 195 | 196 | if (libName && av + nav > 0) { 197 | responseHoldings.availability.push({ 198 | library: libName, 199 | available: av, 200 | unavailable: nav 201 | }) 202 | } 203 | }) 204 | return common.endResponse(responseHoldings) 205 | } 206 | 207 | // Get the item holdings widget 208 | const holdingsPanelHeader = { 209 | Accept: 'text/xml', 210 | 'Wicket-Ajax': true 211 | } 212 | const holdingsPanelUrl = service.Url + HOLDINGS_URL_PORTLET 213 | 214 | const holdingsPanelPortletResponse = await agent 215 | .get(holdingsPanelUrl) 216 | .set(holdingsPanelHeader) 217 | .timeout(20000) 218 | const js = await xml2js.parseStringPromise( 219 | holdingsPanelPortletResponse.text 220 | ) 221 | 222 | if (!js['ajax-response'] || !js['ajax-response'].component) 223 | return common.endResponse(responseHoldings) 224 | $ = cheerio.load(js['ajax-response'].component[0]._) 225 | 226 | if ( 227 | $( 228 | '.arena-holding-nof-total, .arena-holding-nof-checked-out, .arena-holding-nof-available-for-loan' 229 | ).length > 0 230 | ) { 231 | $('.arena-holding-child-container').each(function (idx, container) { 232 | const libName = $(container).find('span.arena-holding-link').text() 233 | const totalAvailable = 234 | $(container) 235 | .find('.arena-holding-nof-total span.arena-value') 236 | .text() || 237 | parseInt( 238 | $(container) 239 | .find('.arena-holding-nof-available-for-loan span.arena-value') 240 | .text() || 0 241 | ) + 242 | parseInt( 243 | $(container) 244 | .find('.arena-holding-nof-checked-out span.arena-value') 245 | .text() || 0 246 | ) 247 | const checkedOut = $(container) 248 | .find('.arena-holding-nof-checked-out span.arena-value') 249 | .text() 250 | 251 | const av = 252 | (totalAvailable ? parseInt(totalAvailable) : 0) - 253 | (checkedOut ? parseInt(checkedOut) : 0) 254 | const nav = checkedOut !== '' ? parseInt(checkedOut) : 0 255 | 256 | if (libName && av + nav > 0) { 257 | responseHoldings.availability.push({ 258 | library: libName, 259 | available: av, 260 | unavailable: nav 261 | }) 262 | } 263 | }) 264 | return common.endResponse(responseHoldings) 265 | } 266 | 267 | let currentOrg = null 268 | $('.arena-holding-hyper-container .arena-holding-container a span').each( 269 | function (i) { 270 | if ( 271 | $(this).text().trim() === (service.OrganisationName || service.Name) 272 | ) 273 | currentOrg = i 274 | } 275 | ) 276 | if (currentOrg == null) return common.endResponse(responseHoldings) 277 | 278 | const holdingsHeaders = { Accept: 'text/xml', 'Wicket-Ajax': true } 279 | holdingsHeaders['Wicket-FocusedElementId'] = 280 | 'id__crDetailWicket__WAR__arenaportlets____2a' 281 | let resourceId = 282 | '/crDetailWicket/?wicket:interface=:0:recordPanel:holdingsPanel:content:holdingsView:' + 283 | (currentOrg + 1) + 284 | ':holdingContainer:togglableLink::IBehaviorListener:0:' 285 | const holdingsUrl = 286 | service.Url + 287 | HOLDINGSDETAIL_URL_PORTLET.replace('[RESOURCEID]', resourceId) 288 | const holdingsResponse = await agent 289 | .get(holdingsUrl) 290 | .set(holdingsHeaders) 291 | .timeout(20000) 292 | const holdingsJs = await xml2js.parseStringPromise(holdingsResponse.text) 293 | $ = cheerio.load(holdingsJs['ajax-response'].component[0]._) 294 | 295 | const libsData = $('.arena-holding-container') 296 | const numLibs = libsData.length 297 | if (!numLibs || numLibs === 0) return common.endResponse(responseHoldings) 298 | 299 | const availabilityRequests = [] 300 | libsData.each(function (i) { 301 | resourceId = 302 | '/crDetailWicket/?wicket:interface=:0:recordPanel:holdingsPanel:content:holdingsView:' + 303 | (currentOrg + 1) + 304 | ':childContainer:childView:' + 305 | i + 306 | ':holdingPanel:holdingContainer:togglableLink::IBehaviorListener:0:' 307 | const libUrl = 308 | service.Url + 309 | HOLDINGSDETAIL_URL_PORTLET.replace('[RESOURCEID]', resourceId) 310 | const headers = { Accept: 'text/xml', 'Wicket-Ajax': true } 311 | availabilityRequests.push(agent.get(libUrl).set(headers).timeout(20000)) 312 | }) 313 | 314 | const responses = await Promise.all(availabilityRequests) 315 | 316 | responses.forEach(async response => { 317 | const availabilityJs = await xml2js.parseStringPromise(response.text) 318 | if (availabilityJs && availabilityJs['ajax-response']) { 319 | $ = cheerio.load(availabilityJs['ajax-response'].component[0]._) 320 | const totalAvailable = $( 321 | '.arena-holding-nof-total span.arena-value' 322 | ).text() 323 | const checkedOut = $( 324 | '.arena-holding-nof-checked-out span.arena-value' 325 | ).text() 326 | $ = cheerio.load(availabilityJs['ajax-response'].component[2]._) 327 | 328 | const av = 329 | (totalAvailable ? parseInt(totalAvailable) : 0) - 330 | (checkedOut ? parseInt(checkedOut) : 0) 331 | const nav = checkedOut ? parseInt(checkedOut) : 0 332 | 333 | if (av + nav > 0) { 334 | responseHoldings.availability.push({ 335 | library: $('span.arena-holding-link').text(), 336 | available: av, 337 | unavailable: nav 338 | }) 339 | } 340 | } 341 | }) 342 | } catch (e) { 343 | responseHoldings.exception = e 344 | } 345 | 346 | return common.endResponse(responseHoldings) 347 | } 348 | -------------------------------------------------------------------------------- /connectors/arena.v8.js: -------------------------------------------------------------------------------- 1 | // HTTP Header: 2 | // Liferay-Portal: Liferay Community Edition Portal 7.0.6 GA7 (Wilberforce / Build 7006 / April 17, 2018) 3 | 4 | const cheerio = require('cheerio') 5 | const querystring = require('querystring') 6 | const request = require('superagent') 7 | const xml2js = require('xml2js') 8 | 9 | const common = require('./common') 10 | const { Cookie } = require('tough-cookie') 11 | 12 | const RESULT_URL = 'results' 13 | 14 | const SEARCH_URL_PORTLET = 15 | 'search?p_p_id=searchResult_WAR_arenaportlet&p_p_lifecycle=1&p_p_state=normal&p_r_p_arena_urn:arena_facet_queries=&p_r_p_arena_urn:arena_search_type=solr&p_r_p_arena_urn:arena_search_query=[BOOKQUERY]' 16 | const ITEM_URL_PORTLET = 17 | 'results?p_p_id=crDetailWicket_WAR_arenaportlet&p_p_lifecycle=1&p_p_state=normal&p_r_p_arena_urn:arena_search_item_id=[ITEMID]&p_r_p_arena_urn:arena_facet_queries=&p_r_p_arena_urn:arena_agency_name=[ARENANAME]&p_r_p_arena_urn:arena_search_item_no=0&p_r_p_arena_urn:arena_search_type=solr' 18 | ;('results?p_p_id=crDetailWicket_WAR_arenaportlet&p_p_lifecycle=1&p_p_state=normal&p_r_p_arena_urn:arena_search_item_id=0747532745&p_r_p_arena_urn:arena_facet_queries=&p_r_p_arena_urn:arena_agency_name=AUKLIBRARIESUNLIMITED&p_r_p_arena_urn:arena_search_item_no=0&p_r_p_arena_urn:arena_search_query=organisationId_index:AUKLIBRARIESUNLIMITED|1 AND number_index:9780747532743&p_r_p_arena_urn:arena_search_type=solr&p_r_p_arena_urn:arena_sort_advice=field=Relevance&direction=Descending') 19 | const HOLDINGS_URL_PORTLET = 20 | 'results?p_p_id=crDetailWicket_WAR_arenaportlet&p_p_lifecycle=2&p_p_state=normal&p_p_mode=view&p_p_resource_id=%2FcrDetailWicket%2F%3Fwicket%3Ainterface%3D%3A0%3ArecordPanel%3Apanel%3AholdingsPanel%3A%3AIBehaviorListener%3A0%3A&p_p_cacheability=cacheLevelPage' 21 | const HOLDINGSDETAIL_URL_PORTLET = 22 | 'results?p_p_id=crDetailWicket_WAR_arenaportlet&p_p_lifecycle=2&p_p_state=normal&p_p_mode=view&p_p_resource_id=[RESOURCEID]&p_p_cacheability=' 23 | 24 | /** 25 | * Gets the object representing the service 26 | * @param {object} service 27 | */ 28 | exports.getService = service => common.getService(service) 29 | 30 | /** 31 | * Gets the libraries in the service based upon possible search and filters within the library catalogue 32 | * @param {object} service 33 | */ 34 | exports.getLibraries = async function (service) { 35 | let botCookie = null 36 | const responseLibraries = common.initialiseGetLibrariesResponse(service) 37 | 38 | try { 39 | const agent = request.agent() 40 | let $ = null 41 | 42 | if (service.SignupUrl) { 43 | // Some installations list libraries on the signup page 44 | const signupResponse = await agent.get(service.SignupUrl).timeout(20000) 45 | $ = cheerio.load(signupResponse.text) 46 | if ($('select[name="branches-div:choiceBranch"] option').length > 1) { 47 | $('select[name="branches-div:choiceBranch"] option').each(function () { 48 | if (common.isLibrary($(this).text())) 49 | responseLibraries.libraries.push($(this).text()) 50 | }) 51 | return common.endResponse(responseLibraries) 52 | } 53 | } 54 | 55 | // Get the advanced search page 56 | const advancedSearchResponse = await agent 57 | .get(service.Url + service.AdvancedUrl) 58 | .set({ Connection: 'keep-alive' }) 59 | .timeout(20000) 60 | 61 | // The advanced search page may have libraries listed on it 62 | $ = cheerio.load(advancedSearchResponse.text) 63 | if ($('.arena-extended-search-branch-choice option').length > 1) { 64 | $('.arena-extended-search-branch-choice option').each(function () { 65 | if (common.isLibrary($(this).text())) 66 | responseLibraries.libraries.push($(this).text()) 67 | }) 68 | return common.endResponse(responseLibraries) 69 | } 70 | 71 | // If not we'll need to call a portlet to get the data 72 | const focusedElementId = $( 73 | '.arena-extended-search-organisation-choice' 74 | ).attr('id') 75 | const headers = { 76 | Accept: 'text/xml', 77 | 'Content-Type': 'application/x-www-form-urlencoded', 78 | 'Wicket-Ajax': true, 79 | 'Wicket-Focusedelementid': focusedElementId 80 | } 81 | const url = service.Url + service.AdvancedUrl 82 | 83 | const formData = querystring.stringify({ 84 | p_p_id: 'extendedSearch_WAR_arenaportlet', 85 | p_p_lifecycle: 2, 86 | p_p_state: 'normal', 87 | p_p_mode: 'view', 88 | p_p_resource_id: 89 | '/extendedSearch/?wicket:interface=:0:extendedSearchPanel:extendedSearchForm:organisationHierarchyPanel:organisationContainer:organisationChoice::IBehaviorListener:0:', 90 | p_p_cacheability: 'cacheLevelPage', 91 | 'organisationHierarchyPanel:organisationContainer:organisationChoice': 92 | service.OrganisationId || '' 93 | }) 94 | const responseHeaderRequest = await agent 95 | .post(url) 96 | .set(headers) 97 | .send(formData) 98 | .timeout(20000) 99 | const js = await xml2js.parseStringPromise(responseHeaderRequest.text) 100 | 101 | // Parse the results of the request 102 | if (js && js !== 'Undeployed' && js['ajax-response']?.component) { 103 | $ = cheerio.load(js['ajax-response'].component[0]._) 104 | $('option').each(function () { 105 | if (common.isLibrary($(this).text())) 106 | responseLibraries.libraries.push($(this).text()) 107 | }) 108 | } 109 | } catch (e) { 110 | responseLibraries.exception = e 111 | } 112 | 113 | return common.endResponse(responseLibraries) 114 | } 115 | 116 | /** 117 | * Retrieves the availability summary of an ISBN by library 118 | * @param {string} isbn 119 | * @param {object} service 120 | */ 121 | exports.searchByISBN = async function (isbn, service) { 122 | let botCookie = null 123 | const responseHoldings = common.initialiseSearchByISBNResponse(service) 124 | 125 | try { 126 | const agent = request.agent() 127 | 128 | // Stage 1: Call the search results 129 | let query = service.SearchType !== 'Keyword' ? 'number_index:' + isbn : isbn 130 | if (service.OrganisationId) { 131 | query = 'organisationId_index:' + service.OrganisationId + '+AND+' + query 132 | } 133 | 134 | const searchUrl = SEARCH_URL_PORTLET.replace('[BOOKQUERY]', query) 135 | responseHoldings.url = service.Url + searchUrl 136 | 137 | let searchResponse = await agent 138 | .get(responseHoldings.url) 139 | .set({ Connection: 'keep-alive' }) 140 | .timeout(20000) 141 | 142 | let cookieResponse = await handleLoadingResponse(agent, searchResponse) 143 | searchResponse = cookieResponse.response 144 | const sessionCookies = searchResponse.headers['set-cookie'] 145 | botCookie = cookieResponse.cookieString 146 | sessionCookies.push(botCookie + ';') 147 | // Remove Secure and HttpOnly flags from cookies 148 | for (let i = 0; i < sessionCookies.length; i++) { 149 | sessionCookies[i] = sessionCookies[i] 150 | .replace(/; Secure/gi, '') 151 | .replace(/; HttpOnly/gi, '') 152 | } 153 | const cookies = sessionCookies.join('; ') 154 | 155 | const resultsText = searchResponse.text 156 | .replace(/\\x3d/g, '=') 157 | .replace(/\\x26/g, '&') 158 | 159 | const itemIdIndex = resultsText && resultsText.lastIndexOf('search_item_id') 160 | if (!itemIdIndex || itemIdIndex === -1) { 161 | return common.endResponse(responseHoldings) 162 | } 163 | 164 | let $ = cheerio.load(resultsText) 165 | 166 | // Stage 2: Get the item details page to retrieve holdings 167 | const itemIdString = resultsText.substring(itemIdIndex + 15) 168 | itemId = itemIdString.substring(0, itemIdString.indexOf('&')) 169 | responseHoldings.id = itemId 170 | 171 | const itemUrlPortlet = ITEM_URL_PORTLET.replace( 172 | '[ARENANAME]', 173 | service.ArenaName 174 | ).replace('[ITEMID]', itemId) 175 | const itemUrl = service.Url + itemUrlPortlet 176 | 177 | await new Promise(resolve => setTimeout(resolve, 1000)) 178 | let itemPageResponse = await agent 179 | .get(itemUrl) 180 | .set({ Cookie: cookies, Connection: 'keep-alive' }) 181 | .timeout(20000) 182 | 183 | $ = cheerio.load(itemPageResponse.text) 184 | 185 | if ($('.arena-availability-viewbranch').length > 0) { 186 | // If the item holdings are available immediately on the page 187 | $('.arena-availability-viewbranch').each(function () { 188 | const libName = $(this).find('.arena-branch-name span').text() 189 | const totalAvailable = $(this) 190 | .find('.arena-availability-info span') 191 | .eq(0) 192 | .text() 193 | .replace('Total ', '') 194 | const checkedOut = $(this) 195 | .find('.arena-availability-info span') 196 | .eq(1) 197 | .text() 198 | .replace('On loan ', '') 199 | const av = 200 | (totalAvailable ? parseInt(totalAvailable) : 0) - 201 | (checkedOut ? parseInt(checkedOut) : 0) 202 | const nav = checkedOut !== '' ? parseInt(checkedOut) : 0 203 | 204 | if (libName && av + nav > 0) { 205 | responseHoldings.availability.push({ 206 | library: libName, 207 | available: av, 208 | unavailable: nav 209 | }) 210 | } 211 | }) 212 | return common.endResponse(responseHoldings) 213 | } 214 | 215 | // Get the item holdings widget 216 | const holdingsPanelHeader = { 217 | Accept: 'text/xml', 218 | 'Content-Type': 'application/x-www-form-urlencoded', 219 | 'Wicket-Ajax': true, 220 | Cookie: cookies 221 | } 222 | 223 | const holdingsUrl = service.Url + RESULT_URL 224 | await new Promise(resolve => setTimeout(resolve, 1000)) 225 | const holdingsPanelPayload = { 226 | p_p_id: 'crDetailWicket_WAR_arenaportlet', 227 | p_p_lifecycle: 2, 228 | p_p_state: 'normal', 229 | p_p_mode: 'view', 230 | p_p_resource_id: 231 | '/crDetailWicket/?wicket:interface=:0:recordPanel:holdingsPanel::IBehaviorListener:0:', 232 | p_p_cacheability: 'cacheLevelPage' 233 | } 234 | const holdingsPanelFormData = querystring.stringify(holdingsPanelPayload) 235 | const holdingsPanelPortletResponse = await agent 236 | .post(holdingsUrl) 237 | .set(holdingsPanelHeader) 238 | .send(holdingsPanelFormData) 239 | .timeout(20000) 240 | const js = await xml2js.parseStringPromise( 241 | holdingsPanelPortletResponse.text 242 | ) 243 | 244 | if (!js['ajax-response'] || !js['ajax-response'].component) 245 | return common.endResponse(responseHoldings) 246 | $ = cheerio.load(js['ajax-response'].component[0]._) 247 | 248 | if ( 249 | $( 250 | '.arena-holding-nof-total, .arena-holding-nof-checked-out, .arena-holding-nof-available-for-loan' 251 | ).length > 0 252 | ) { 253 | $('.arena-holding-child-container').each(function (idx, cont) { 254 | const libName = $(cont).find('span.arena-holding-link').text() 255 | const totalAvailable = 256 | $(cont).find('.arena-holding-nof-total span.arena-value').text() || 257 | parseInt( 258 | $(cont) 259 | .find('.arena-holding-nof-available-for-loan span.arena-value') 260 | .text() || 0 261 | ) + 262 | parseInt( 263 | $(cont) 264 | .find('.arena-holding-nof-checked-out span.arena-value') 265 | .text() || 0 266 | ) 267 | const checkedOut = $(cont) 268 | .find('.arena-holding-nof-checked-out span.arena-value') 269 | .text() 270 | 271 | const av = 272 | (totalAvailable ? parseInt(totalAvailable) : 0) - 273 | (checkedOut ? parseInt(checkedOut) : 0) 274 | const nav = checkedOut !== '' ? parseInt(checkedOut) : 0 275 | 276 | if (libName && av + nav > 0) { 277 | responseHoldings.availability.push({ 278 | library: libName, 279 | available: av, 280 | unavailable: nav 281 | }) 282 | } 283 | }) 284 | return common.endResponse(responseHoldings) 285 | } 286 | 287 | let currentOrg = null 288 | let linkId = null 289 | let holdingsLink = null 290 | $('.arena-holding-hyper-container .arena-holding-container a span').each( 291 | function (i) { 292 | const text = $(this).text().trim() 293 | const id = $(this).parent().attr('id') 294 | const link = $(this).parent().attr('href') 295 | if (text === (service.OrganisationName || service.Name)) { 296 | currentOrg = i 297 | linkId = id 298 | holdingsLink = link 299 | } 300 | } 301 | ) 302 | if (currentOrg == null) return common.endResponse(responseHoldings) 303 | 304 | const holdingsHeaders = { 305 | Accept: 'text/xml', 306 | 'Content-Type': 'application/x-www-form-urlencoded', 307 | 'Wicket-Ajax': true, 308 | 'Wicket-Focusedelementid': linkId 309 | } 310 | 311 | // Get the interface id from the link 312 | const holdingsLinkParts = holdingsLink.split('?')[1] 313 | const holdingsLinkPartsObj = querystring.parse(holdingsLinkParts) 314 | const p_p_resource_id = holdingsLinkPartsObj.p_p_resource_id 315 | const interfaceId = p_p_resource_id.substring( 316 | p_p_resource_id.indexOf('wicket:interface=') + 18, 317 | p_p_resource_id.indexOf(':recordPanel') 318 | ) 319 | 320 | const holdingsFormData = { 321 | p_p_id: 'crDetailWicket_WAR_arenaportlet', 322 | p_p_lifecycle: 2, 323 | p_p_state: 'normal', 324 | p_p_mode: 'view', 325 | p_p_resource_id: `/crDetailWicket/?wicket:interface=:${interfaceId}:recordPanel:panel:holdingsPanel:content:holdingsView:${ 326 | currentOrg + 1 327 | }:holdingContainer:togglableLink::IBehaviorListener:0:`, 328 | p_p_cacheability: 'cacheLevelPage' 329 | } 330 | 331 | // Add the form data to the request body 332 | const formData = querystring.stringify(holdingsFormData) 333 | const holdingsResponse = await agent 334 | .post(holdingsUrl) 335 | .set(holdingsHeaders) 336 | .set({ cookie: botCookie }) 337 | .send(formData) 338 | .timeout(20000) 339 | const holdingsJs = await xml2js.parseStringPromise(holdingsResponse.text) 340 | $ = cheerio.load(holdingsJs['ajax-response'].component[0]._) 341 | 342 | const libsData = $('.arena-holding-container') 343 | const numLibs = libsData.length 344 | if (!numLibs || numLibs === 0) return common.endResponse(responseHoldings) 345 | 346 | const availabilityRequests = [] 347 | libsData.each(function (i, cont) { 348 | const linkId = $(cont).find('a')[0].attribs.id 349 | resourceId = `/crDetailWicket/?wicket:interface=:${interfaceId}:recordPanel:panel:holdingsPanel:content:holdingsView:${ 350 | currentOrg + 1 351 | }:childContainer:childView:${i}:holdingPanel:holdingContainer:togglableLink::IBehaviorListener:0:` 352 | const libUrl = 353 | service.Url + 354 | HOLDINGSDETAIL_URL_PORTLET.replace('[RESOURCEID]', resourceId) 355 | const headers = { 356 | Accept: 'text/xml', 357 | 'Wicket-Ajax': true, 358 | 'Wicket-FocusedElementId': linkId 359 | } 360 | availabilityRequests.push( 361 | agent.get(libUrl).set(headers).set({ cookie: botCookie }).timeout(20000) 362 | ) 363 | }) 364 | 365 | const responses = await Promise.all(availabilityRequests) 366 | 367 | responses.forEach(async response => { 368 | const availabilityJs = await xml2js.parseStringPromise(response.text) 369 | if (availabilityJs && availabilityJs['ajax-response']) { 370 | $ = cheerio.load(availabilityJs['ajax-response'].component[0]._) 371 | const totalAvailable = $( 372 | '.arena-holding-nof-total span.arena-value' 373 | ).text() 374 | const checkedOut = $( 375 | '.arena-holding-nof-checked-out span.arena-value' 376 | ).text() 377 | $ = cheerio.load(availabilityJs['ajax-response'].component[2]._) 378 | 379 | const av = 380 | (totalAvailable ? parseInt(totalAvailable) : 0) - 381 | (checkedOut ? parseInt(checkedOut) : 0) 382 | const nav = checkedOut ? parseInt(checkedOut) : 0 383 | 384 | if (av + nav > 0) { 385 | responseHoldings.availability.push({ 386 | library: $('span.arena-holding-link').text(), 387 | available: av, 388 | unavailable: nav 389 | }) 390 | } 391 | } 392 | }) 393 | } catch (e) { 394 | responseHoldings.exception = e 395 | } 396 | 397 | return common.endResponse(responseHoldings) 398 | } 399 | 400 | const isLoadingPage = response => { 401 | return response && response.text && response.text.indexOf('Loading...') !== -1 402 | } 403 | 404 | const handleLoadingResponse = async (agent, response) => { 405 | if (!response || !response.text) return response 406 | 407 | let cookieString = null 408 | 409 | // If loading page is detected follow the procedure to run the javascript 410 | if (isLoadingPage(response)) { 411 | let tries = 0 412 | while (tries < 2 && response && isLoadingPage(response)) { 413 | const respText = response.text 414 | const leastFactorStart = respText.indexOf('function leastFactor(n) {') 415 | const leastFactorEnd = respText.indexOf('return n;', leastFactorStart) + 9 416 | const leastFactorString = respText.substring( 417 | leastFactorStart, 418 | leastFactorEnd 419 | ) 420 | const goStart = respText.indexOf('function go() {') + 15 421 | const goEnd = 422 | respText.indexOf('document.location.reload(true); }', goStart) + 33 423 | 424 | let goString = respText 425 | .substring(goStart, goEnd) 426 | .replace('document.location.reload(true);', '') 427 | .replace('document.cookie=', '; return ') 428 | 429 | // Embed the least factor function inside the go function 430 | goString = `${leastFactorString}}\n${goString}` 431 | 432 | // We need to dynamically create a function to execute the code returned 433 | const go = Function(goString) 434 | 435 | cookieString = go() 436 | 437 | response = await agent 438 | .get(response.request.url) 439 | .set({ cookie: cookieString }) 440 | .timeout(20000) 441 | tries += 1 442 | await new Promise(resolve => setTimeout(resolve, 1000)) 443 | } 444 | return { response, cookieString } 445 | } else { 446 | return { response, cookieString } 447 | } 448 | } 449 | -------------------------------------------------------------------------------- /tests/tests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Name": "Aberdeen City", 4 | "ISBNs": [ 5 | "9781408855652", 6 | "9780141187761", 7 | "9780141439518", 8 | "9780416179200", 9 | "9780007371464" 10 | ] 11 | }, 12 | { 13 | "Name": "Aberdeenshire", 14 | "ISBNs": [ 15 | "9780747532743", 16 | "9780141187761", 17 | "9780141439518", 18 | "9780140707342", 19 | "9780007371464" 20 | ] 21 | }, 22 | { 23 | "Name": "Sir Ynys Mon - Isle of Anglesey", 24 | "ISBNs": [ 25 | "9780747532743", 26 | "9780141187761", 27 | "9780141439518", 28 | "9780140707342", 29 | "9780007371464" 30 | ] 31 | }, 32 | { 33 | "Name": "Angus", 34 | "ISBNs": [ 35 | "9780747532743", 36 | "9780141187761", 37 | "9780099511151", 38 | "9780141396507", 39 | "9780007371464" 40 | ] 41 | }, 42 | { 43 | "Name": "Argyll and Bute", 44 | "ISBNs": [ 45 | "9781408855652", 46 | "9780140126716", 47 | "9780140430721", 48 | "9780192814487", 49 | "9780007371464" 50 | ] 51 | }, 52 | { 53 | "Name": "Barking and Dagenham", 54 | "ISBNs": [ 55 | "9780747532743", 56 | "9780141187761", 57 | "9780141439518", 58 | "9780521618748", 59 | "9780007371464" 60 | ] 61 | }, 62 | { 63 | "Name": "Barnet", 64 | "ISBNs": [ 65 | "9781408855652", 66 | "9780141187761", 67 | "9780141439518", 68 | "9781904271338", 69 | "9780007371464" 70 | ] 71 | }, 72 | { 73 | "Name": "Barnsley", 74 | "ISBNs": [ 75 | "9780747532743", 76 | "9780141187761", 77 | "9780141439518", 78 | "9780140707342", 79 | "9780007371464" 80 | ] 81 | }, 82 | { 83 | "Name": "Bath and North East Somerset", 84 | "ISBNs": [ 85 | "9780747532743", 86 | "9780141187761", 87 | "9780553213102", 88 | "9780521618748", 89 | "9780007371464" 90 | ] 91 | }, 92 | { 93 | "Name": "Bedford", 94 | "ISBNs": [ 95 | "9781408855652", 96 | "9780141036144", 97 | "9780141199078", 98 | "9780416179200", 99 | "9780007371464" 100 | ] 101 | }, 102 | { 103 | "Name": "Bexley", 104 | "ISBNs": [ 105 | "9781408855652", 106 | "9780141036144", 107 | "9780141199078", 108 | "9780416179200", 109 | "9780007371464" 110 | ] 111 | }, 112 | { 113 | "Name": "Birmingham", 114 | "ISBNs": [ 115 | "9780747532743", 116 | "9780141187761", 117 | "9780141439518", 118 | "9780521618748", 119 | "9780007371464" 120 | ] 121 | }, 122 | { 123 | "Name": "Blackburn with Darwen", 124 | "ISBNs": [ 125 | "9781408855652", 126 | "9780141187761", 127 | "9780141439518", 128 | "9780521532525", 129 | "9780007371464" 130 | ] 131 | }, 132 | { 133 | "Name": "Blackpool", 134 | "ISBNs": [ 135 | "9780747532743", 136 | "9780141187761", 137 | "9780141439518", 138 | "9780521618748", 139 | "9780007371464" 140 | ] 141 | }, 142 | { 143 | "Name": "Blaenau Gwent", 144 | "ISBNs": [ 145 | "9780747532743", 146 | "9780141187761", 147 | "9780141439518", 148 | "9780750253901", 149 | "9780007371464" 150 | ] 151 | }, 152 | { 153 | "Name": "Bolton", 154 | "ISBNs": [ 155 | "9781408855652", 156 | "9780141187761", 157 | "9780141439518", 158 | "9781904271338", 159 | "9780007371464" 160 | ] 161 | }, 162 | { 163 | "Name": "Bournemouth, Christchurch and Poole", 164 | "ISBNs": [ 165 | "9780747532743", 166 | "9780141036144", 167 | "9780141439518", 168 | "9781904271338", 169 | "9780007371464" 170 | ] 171 | }, 172 | { 173 | "Name": "Bracknell Forest", 174 | "ISBNs": [ 175 | "9780747532743", 176 | "9780141187761", 177 | "9781604501483", 178 | "9781904271338", 179 | "9780007371464" 180 | ] 181 | }, 182 | { 183 | "Name": "Bradford", 184 | "ISBNs": [ 185 | "9780747532743", 186 | "9780141187761", 187 | "9780141439518", 188 | "9780140707342", 189 | "9780007371464" 190 | ] 191 | }, 192 | { 193 | "Name": "Brent", 194 | "ISBNs": [ 195 | "9780747532743", 196 | "9780141187761", 197 | "9780141439518", 198 | "9780521618748", 199 | "9780007371464" 200 | ] 201 | }, 202 | { 203 | "Name": "Pen-y-bont ar Ogwr - Bridgend", 204 | "ISBNs": [ 205 | "9780747532743", 206 | "9780141036144", 207 | "9781853260001", 208 | "9780140707342", 209 | "9780007371464" 210 | ] 211 | }, 212 | { 213 | "Name": "Brighton and Hove", 214 | "ISBNs": [ 215 | "9781408855652", 216 | "9780141187761", 217 | "9780141439518", 218 | "9781904271338", 219 | "9780007371464" 220 | ] 221 | }, 222 | { 223 | "Name": "Bristol City", 224 | "ISBNs": [ 225 | "9780747532743", 226 | "9780141187761", 227 | "9780553213102", 228 | "9780521618748", 229 | "9780007371464" 230 | ] 231 | }, 232 | { 233 | "Name": "Bromley", 234 | "ISBNs": [ 235 | "9780747532743", 236 | "9780141036144", 237 | "9780099589334", 238 | "9781904271338", 239 | "9780007371464" 240 | ] 241 | }, 242 | { 243 | "Name": "Buckinghamshire", 244 | "ISBNs": [ 245 | "9780590353403", 246 | "9780141187761", 247 | "9780140434262", 248 | "9780140707342", 249 | "9780007371464" 250 | ] 251 | }, 252 | { 253 | "Name": "Bury", 254 | "ISBNs": [ 255 | "9780747532743", 256 | "9780141187761", 257 | "9780141439518", 258 | "9780143128540", 259 | "9780007371464" 260 | ] 261 | }, 262 | { 263 | "Name": "Caerphilly", 264 | "ISBNs": [ 265 | "9780747532743", 266 | "9780141187761", 267 | "9780141439518", 268 | "9780521618748", 269 | "9780007371464" 270 | ] 271 | }, 272 | { 273 | "Name": "Calderdale", 274 | "ISBNs": [ 275 | "9780747532743", 276 | "9780140126716", 277 | "9780141439518", 278 | "9780521532525", 279 | "9780007371464" 280 | ] 281 | }, 282 | { 283 | "Name": "Cambridgeshire", 284 | "ISBNs": [ 285 | "9780747532743", 286 | "9780141187761", 287 | "9780679783268", 288 | "9780521618748", 289 | "9780007371464" 290 | ] 291 | }, 292 | { 293 | "Name": "Camden", 294 | "ISBNs": [ 295 | "9780590353403", 296 | "9780141187761", 297 | "9780141439518", 298 | "9780521618748", 299 | "9780007371464" 300 | ] 301 | }, 302 | { 303 | "Name": "Caerdydd - Cardiff", 304 | "ISBNs": [ 305 | "9780747532743", 306 | "9780141187761", 307 | "9781853260001", 308 | "9781904271338", 309 | "9780007371464" 310 | ] 311 | }, 312 | { 313 | "Name": "Sir Gaerfyrddin - Carmarthenshire", 314 | "ISBNs": [ 315 | "9780747532743", 316 | "9780141036144", 317 | "9780141439518", 318 | "9780174434696", 319 | "9780007371464" 320 | ] 321 | }, 322 | { 323 | "Name": "Central Bedfordshire", 324 | "ISBNs": [ 325 | "9781408855652", 326 | "9780141036144", 327 | "9780141439518", 328 | "9781472518385", 329 | "9780007371464" 330 | ] 331 | }, 332 | { 333 | "Name": "Sir Ceredigion - Ceredigion", 334 | "ISBNs": [ 335 | "9780747532743", 336 | "9780141187761", 337 | "9780141439518", 338 | "9780140620580", 339 | "9780007371464" 340 | ] 341 | }, 342 | { 343 | "Name": "Cheshire East", 344 | "ISBNs": [ 345 | "9780747532743", 346 | "9780141187761", 347 | "9780141439518", 348 | "9780521618748", 349 | "9780007371464" 350 | ] 351 | }, 352 | { 353 | "Name": "Cheshire West and Chester", 354 | "ISBNs": [ 355 | "9780747532743", 356 | "9780141187761", 357 | "9780141439518", 358 | "9780521618748", 359 | "9780007371464" 360 | ] 361 | }, 362 | { 363 | "Name": "City of London", 364 | "ISBNs": [ 365 | "9780747573609", 366 | "9780679417392", 367 | "9780141439518", 368 | "9781904271338", 369 | "9780007371464" 370 | ] 371 | }, 372 | { 373 | "Name": "Clackmannanshire", 374 | "ISBNs": [ 375 | "9781408855652", 376 | "NO-1984", 377 | "9780199535569", 378 | "9780141396507", 379 | "9780007371464" 380 | ] 381 | }, 382 | { 383 | "Name": "Conwy", 384 | "ISBNs": [ 385 | "9780747532743", 386 | "9780141187761", 387 | "9780141439518", 388 | "9780140707342", 389 | "9780007371464" 390 | ] 391 | }, 392 | { 393 | "Name": "Cornwall", 394 | "ISBNs": [ 395 | "9781408855652", 396 | "9780141036144", 397 | "9780099511151", 398 | "9781903436677", 399 | "9780007371464" 400 | ] 401 | }, 402 | { 403 | "Name": "Coventry", 404 | "ISBNs": [ 405 | "9780747554561", 406 | "9780141187761", 407 | "9780141439518", 408 | "9780198321071", 409 | "9780007371464" 410 | ] 411 | }, 412 | { 413 | "Name": "Croydon", 414 | "ISBNs": [ 415 | "9780747532743", 416 | "9780141187761", 417 | "9780141439518", 418 | "9780521618748", 419 | "9780007371464" 420 | ] 421 | }, 422 | { 423 | "Name": "Cumberland", 424 | "ISBNs": [ 425 | "9780747532743", 426 | "9780141187761", 427 | "9780141439518", 428 | "9780140707342", 429 | "9780007371464" 430 | ] 431 | }, 432 | { 433 | "Name": "Darlington", 434 | "ISBNs": [ 435 | "9781408855652", 436 | "9780241341650", 437 | "9780708982280", 438 | "9781904271338", 439 | "9780007371464" 440 | ] 441 | }, 442 | { 443 | "Name": "Sir Ddinbych - Denbighshire", 444 | "ISBNs": [ 445 | "9780747532743", 446 | "9780141187761", 447 | "9780141439518", 448 | "9780140707342", 449 | "9780007371464" 450 | ] 451 | }, 452 | { 453 | "Name": "Derby City", 454 | "ISBNs": [ 455 | "9780747532743", 456 | "9780141036144", 457 | "9780141439518", 458 | "9780141396507", 459 | "9780007371464" 460 | ] 461 | }, 462 | { 463 | "Name": "Derbyshire", 464 | "ISBNs": [ 465 | "9780747532743", 466 | "9780141036144", 467 | "9780141439518", 468 | "9781904271338", 469 | "9780007371464" 470 | ] 471 | }, 472 | { 473 | "Name": "Devon", 474 | "ISBNs": [ 475 | "9780747532743", 476 | "9780141187761", 477 | "9780141439518", 478 | "9780521618748", 479 | "9780007371464" 480 | ] 481 | }, 482 | { 483 | "Name": "Doncaster", 484 | "ISBNs": [ 485 | "9780747532743", 486 | "9780141036144", 487 | "9780141439518", 488 | "9781904271338", 489 | "9780007371464" 490 | ] 491 | }, 492 | { 493 | "Name": "Dorset", 494 | "ISBNs": [ 495 | "9780747532743", 496 | "9780141187761", 497 | "9780553213102", 498 | "9780521618748", 499 | "9780007371464" 500 | ] 501 | }, 502 | { 503 | "Name": "Dudley", 504 | "ISBNs": [ 505 | "9780747532743", 506 | "9780141036144", 507 | "9780192802385", 508 | "9781904271338", 509 | "9780007371464" 510 | ] 511 | }, 512 | { 513 | "Name": "Dumfries and Galloway", 514 | "ISBNs": [ 515 | "9780747532743", 516 | "9780141187761", 517 | "9780141439518", 518 | "9780140714050", 519 | "9780007371464" 520 | ] 521 | }, 522 | { 523 | "Name": "Dundee City", 524 | "ISBNs": [ 525 | "9780747532743", 526 | "9780141187761", 527 | "9780141439518", 528 | "9781903436677", 529 | "9780007371464" 530 | ] 531 | }, 532 | { 533 | "Name": "Durham", 534 | "ISBNs": [ 535 | "9780747532743", 536 | "9780141187761", 537 | "9780141439518", 538 | "0141013079", 539 | "9780007371464" 540 | ] 541 | }, 542 | { 543 | "Name": "Ealing", 544 | "ISBNs": [ 545 | "9780747532743", 546 | "9780141187761", 547 | "9780141439518", 548 | "9780521618748", 549 | "9780007371464" 550 | ] 551 | }, 552 | { 553 | "Name": "East Ayrshire", 554 | "ISBNs": [ 555 | "9780747532743", 556 | "9780141187761", 557 | "9780141439518", 558 | "9780140707342", 559 | "9780007371464" 560 | ] 561 | }, 562 | { 563 | "Name": "East Dunbartonshire", 564 | "ISBNs": [ 565 | "9781408855652", 566 | "9780141036144", 567 | "9780141439518", 568 | "9780192755513", 569 | "9780007371464" 570 | ] 571 | }, 572 | { 573 | "Name": "East Lothian", 574 | "ISBNs": [ 575 | "9780747532743", 576 | "9780141187761", 577 | "9780141439518", 578 | "9780140707342", 579 | "9780007371464" 580 | ] 581 | }, 582 | { 583 | "Name": "East Renfrewshire", 584 | "ISBNs": [ 585 | "9780747532743", 586 | "9780141187761", 587 | "9780141439518", 588 | "9780140707342", 589 | "9780007371464" 590 | ] 591 | }, 592 | { 593 | "Name": "East Riding of Yorkshire", 594 | "ISBNs": [ 595 | "9780747532743", 596 | "9780141036144", 597 | "9780199535569", 598 | "9780750030007", 599 | "9780007371464" 600 | ] 601 | }, 602 | { 603 | "Name": "East Sussex", 604 | "ISBNs": [ 605 | "9781408855652", 606 | "9780141187761", 607 | "9780141439518", 608 | "9780521618748", 609 | "9780007371464" 610 | ] 611 | }, 612 | { 613 | "Name": "Edinburgh", 614 | "ISBNs": [ 615 | "9780747532743", 616 | "9780141187761", 617 | "9780141439518", 618 | "9780521532525", 619 | "9780007371464" 620 | ] 621 | }, 622 | { 623 | "Name": "Enfield", 624 | "ISBNs": [ 625 | "9780747532743", 626 | "9780141187761", 627 | "9780141439518", 628 | "9780521618748", 629 | "9780007371464" 630 | ] 631 | }, 632 | { 633 | "Name": "Essex", 634 | "ISBNs": [ 635 | "9780747532743", 636 | "9780141187761", 637 | "9780141439518", 638 | "9780521618748", 639 | "9780007371464" 640 | ] 641 | }, 642 | { 643 | "Name": "Falkirk", 644 | "ISBNs": [ 645 | "9781408855652", 646 | "9780141036144", 647 | "9781843175698", 648 | "9780140620580", 649 | "9780007542994" 650 | ] 651 | }, 652 | { 653 | "Name": "Fife", 654 | "ISBNs": [ 655 | "9780195798586", 656 | "9780141036144", 657 | "9780141439518", 658 | "9780141013077", 659 | "9780007371464" 660 | ] 661 | }, 662 | { 663 | "Name": "Sir y Fflint - Flintshire", 664 | "ISBNs": [ 665 | "9780747532743", 666 | "9780141187761", 667 | "9780141439518", 668 | "9780140707342", 669 | "9780007371464" 670 | ] 671 | }, 672 | { 673 | "Name": "Gateshead", 674 | "ISBNs": [ 675 | "9781408855652", 676 | "9780141393049", 677 | "9780141439518", 678 | "9780174434696", 679 | "9780007371464" 680 | ] 681 | }, 682 | { 683 | "Name": "Glasgow", 684 | "ISBNs": [ 685 | "9780747532743", 686 | "9780141187761", 687 | "9780141439518", 688 | "9780521293662", 689 | "9780007371464" 690 | ] 691 | }, 692 | { 693 | "Name": "Gloucestershire", 694 | "ISBNs": [ 695 | "9780747532743", 696 | "9780141187761", 697 | "9780141439518", 698 | "9780141013077", 699 | "9780007371464" 700 | ] 701 | }, 702 | { 703 | "Name": "Greenwich", 704 | "ISBNs": [ 705 | "9780747532743", 706 | "9780141036144", 707 | "9780141439518", 708 | "9781904271338", 709 | "9780007371464" 710 | ] 711 | }, 712 | { 713 | "Name": "Guernsey", 714 | "ISBNs": [ 715 | "9781408855652", 716 | "9780141036144", 717 | "9780007350773", 718 | "9781472518385", 719 | "9780007371440" 720 | ] 721 | }, 722 | { 723 | "Name": "Gwynedd", 724 | "ISBNs": [ 725 | "9780747532743", 726 | "9780141187761", 727 | "9780141439518", 728 | "9780140707342", 729 | "9780007371464" 730 | ] 731 | }, 732 | { 733 | "Name": "Hackney", 734 | "ISBNs": [ 735 | "9780747532743", 736 | "9780141187761", 737 | "9780141439518", 738 | "9780521618748", 739 | "9780007371464" 740 | ] 741 | }, 742 | { 743 | "Name": "Halton", 744 | "ISBNs": [ 745 | "9781408855652", 746 | "9780141187761", 747 | "9780141439518", 748 | "9780140707342", 749 | "9780007371464" 750 | ] 751 | }, 752 | { 753 | "Name": "Hammersmith and Fulham", 754 | "ISBNs": [ 755 | "9780747532743", 756 | "9780141187761", 757 | "9780141439518", 758 | "9780521618748", 759 | "9780007371464" 760 | ] 761 | }, 762 | { 763 | "Name": "Hampshire", 764 | "ISBNs": [ 765 | "9780747532743", 766 | "9780141187761", 767 | "9780141439518", 768 | "9780140707342", 769 | "9780007371464" 770 | ] 771 | }, 772 | { 773 | "Name": "Haringey", 774 | "ISBNs": [ 775 | "9780747532743", 776 | "9780141187761", 777 | "9780141439518", 778 | "9780140707342", 779 | "9780007371464" 780 | ] 781 | }, 782 | { 783 | "Name": "Harrow", 784 | "ISBNs": [ 785 | "9780747532743", 786 | "9780141187761", 787 | "9780141439518", 788 | "9780521618748", 789 | "9780007371464" 790 | ] 791 | }, 792 | { 793 | "Name": "Hartlepool", 794 | "ISBNs": [ 795 | "9781408855652", 796 | "9780141187761", 797 | "9780141439518", 798 | "9780141396507", 799 | "9780007371464" 800 | ] 801 | }, 802 | { 803 | "Name": "Havering", 804 | "ISBNs": [ 805 | "9780747532743", 806 | "9780141187761", 807 | "9780141439518", 808 | "9780521618748", 809 | "9780007371464" 810 | ] 811 | }, 812 | { 813 | "Name": "Herefordshire", 814 | "ISBNs": [ 815 | "9781408855652", 816 | "9780141036144", 817 | "9780099511151", 818 | "9780141013077", 819 | "9780007371464" 820 | ] 821 | }, 822 | { 823 | "Name": "Hertfordshire", 824 | "ISBNs": [ 825 | "9780747532743", 826 | "9780141187761", 827 | "9780755331468", 828 | "9781903436677", 829 | "9780007371464" 830 | ] 831 | }, 832 | { 833 | "Name": "Highland", 834 | "ISBNs": [ 835 | "9780747532743", 836 | "9780141187761", 837 | "9780099511151", 838 | "9780004105024", 839 | "9780007371464" 840 | ] 841 | }, 842 | { 843 | "Name": "Hillingdon", 844 | "ISBNs": [ 845 | "9780747532743", 846 | "9780141187761", 847 | "9780141439518", 848 | "9780521618748", 849 | "9780007371464" 850 | ] 851 | }, 852 | { 853 | "Name": "Hounslow", 854 | "ISBNs": [ 855 | "9780747532743", 856 | "9780141187761", 857 | "9780141439518", 858 | "9780521618748", 859 | "9780007371464" 860 | ] 861 | }, 862 | { 863 | "Name": "Kingston upon Hull", 864 | "ISBNs": [ 865 | "9780747532743", 866 | "9780141187761", 867 | "9781904633013", 868 | "9780521618748", 869 | "9780007371464" 870 | ] 871 | }, 872 | { 873 | "Name": "Inverclyde", 874 | "ISBNs": [ 875 | "9781408855652", 876 | "9780141393049", 877 | "9780199535569", 878 | "9780312089863", 879 | "9780007371464" 880 | ] 881 | }, 882 | { 883 | "Name": "Isle of Wight", 884 | "ISBNs": [ 885 | "9780747532743", 886 | "9780141187761", 887 | "9780140434262", 888 | "9781904271338", 889 | "9780007371464" 890 | ] 891 | }, 892 | { 893 | "Name": "Islington", 894 | "ISBNs": [ 895 | "9780590353403", 896 | "9780141187761", 897 | "9780141439518", 898 | "9780230217874", 899 | "9780007371464" 900 | ] 901 | }, 902 | { 903 | "Name": "Jersey", 904 | "ISBNs": [ 905 | "9780747532743", 906 | "9780141187761", 907 | "9780141439518", 908 | "9781904271338", 909 | "9780007371464" 910 | ] 911 | }, 912 | { 913 | "Name": "Kensington and Chelsea", 914 | "ISBNs": [ 915 | "9780747532743", 916 | "9780141187761", 917 | "9780141439518", 918 | "9780140707342", 919 | "9780007371464" 920 | ] 921 | }, 922 | { 923 | "Name": "Kent", 924 | "ISBNs": [ 925 | "9780747532743", 926 | "9780141187761", 927 | "9780141439518", 928 | "9780521618748", 929 | "9780007371464" 930 | ] 931 | }, 932 | { 933 | "Name": "Kingston upon Thames", 934 | "ISBNs": [ 935 | "9780747532743", 936 | "9780141187761", 937 | "9780141439518", 938 | "9781904271338", 939 | "9780007371464" 940 | ] 941 | }, 942 | { 943 | "Name": "Kirklees", 944 | "ISBNs": [ 945 | "9780747532743", 946 | "9780141187761", 947 | "9780141439518", 948 | "9780521618748", 949 | "9780007371464" 950 | ] 951 | }, 952 | { 953 | "Name": "Knowsley", 954 | "ISBNs": [ 955 | "9781408855652", 956 | "9781472133038", 957 | "9780141439518", 958 | "9781472518385", 959 | "9780007371464" 960 | ] 961 | }, 962 | { 963 | "Name": "Lambeth", 964 | "ISBNs": [ 965 | "9780747554561", 966 | "9780451524935", 967 | "9780553213102", 968 | "9780140707342", 969 | "9780007371464" 970 | ] 971 | }, 972 | { 973 | "Name": "Lancashire", 974 | "ISBNs": [ 975 | "9781408855652", 976 | "9780141187761", 977 | "9780141439518", 978 | "9780192834164", 979 | "9780007371464" 980 | ] 981 | }, 982 | { 983 | "Name": "Leeds", 984 | "ISBNs": [ 985 | "9780195798586", 986 | "9780141187761", 987 | "9780141439518", 988 | "9780140707342", 989 | "9780007371464" 990 | ] 991 | }, 992 | { 993 | "Name": "Leicester City", 994 | "ISBNs": [ 995 | "9780747532743", 996 | "9780141036144", 997 | "9780141439518", 998 | "9780140707342", 999 | "9780007371464" 1000 | ] 1001 | }, 1002 | { 1003 | "Name": "Leicestershire", 1004 | "ISBNs": [ 1005 | "9780747532743", 1006 | "9780141187761", 1007 | "9780141439518", 1008 | "9781904271338", 1009 | "9780007371464" 1010 | ] 1011 | }, 1012 | { 1013 | "Name": "Lewisham", 1014 | "ISBNs": [ 1015 | "9780747532743", 1016 | "9780141187761", 1017 | "9780141439518", 1018 | "9780521618748", 1019 | "9780007371464" 1020 | ] 1021 | }, 1022 | { 1023 | "Name": "Lincolnshire", 1024 | "ISBNs": [ 1025 | "9781408855652", 1026 | "9780141036144", 1027 | "9780141439518", 1028 | "9780140707342", 1029 | "9780007371464" 1030 | ] 1031 | }, 1032 | { 1033 | "Name": "Liverpool", 1034 | "ISBNs": [ 1035 | "9780747532743", 1036 | "9780141187761", 1037 | "9780141439518", 1038 | "9780140707342", 1039 | "9780007371464" 1040 | ] 1041 | }, 1042 | { 1043 | "Name": "Luton", 1044 | "ISBNs": [ 1045 | "9780747532743", 1046 | "9780141187761", 1047 | "9780141439518", 1048 | "9780521618748", 1049 | "9780007371464" 1050 | ] 1051 | }, 1052 | { 1053 | "Name": "Manchester", 1054 | "ISBNs": [ 1055 | "9780747532743", 1056 | "9780141187761", 1057 | "9780141439518", 1058 | "9780140620580", 1059 | "9780007371464" 1060 | ] 1061 | }, 1062 | { 1063 | "Name": "Medway", 1064 | "ISBNs": [ 1065 | "9780747532743", 1066 | "9780141391700", 1067 | "9780141199078", 1068 | "9780521434942", 1069 | "9780007371464" 1070 | ] 1071 | }, 1072 | { 1073 | "Name": "Merthyr Tudful - Merthyr Tydfil", 1074 | "ISBNs": [ 1075 | "9781408855652", 1076 | "9780141187761", 1077 | "9781853260001", 1078 | "9780141013077", 1079 | "9780007371464" 1080 | ] 1081 | }, 1082 | { 1083 | "Name": "Merton", 1084 | "ISBNs": [ 1085 | "9780747532743", 1086 | "9780141187761", 1087 | "9780141439518", 1088 | "9780521618748", 1089 | "9780007371464" 1090 | ] 1091 | }, 1092 | { 1093 | "Name": "Middlesbrough", 1094 | "ISBNs": [ 1095 | "9781408855652", 1096 | "9780141036144", 1097 | "9780486440910", 1098 | "9780521434942", 1099 | "9780007371464" 1100 | ] 1101 | }, 1102 | { 1103 | "Name": "Midlothian", 1104 | "ISBNs": [ 1105 | "9780747532743", 1106 | "9780141187761", 1107 | "9780099589334", 1108 | "9780141013077", 1109 | "9780007371464" 1110 | ] 1111 | }, 1112 | { 1113 | "Name": "Milton Keynes", 1114 | "ISBNs": [ 1115 | "9780195798586", 1116 | "9780141187761", 1117 | "9780140620221", 1118 | "9781904271338", 1119 | "9780007371464" 1120 | ] 1121 | }, 1122 | { 1123 | "Name": "Sir Fynwy - Monmouthshire", 1124 | "ISBNs": [ 1125 | "9780747532743", 1126 | "9780141036144", 1127 | "9780486284736", 1128 | "9780486272788", 1129 | "9780007371464" 1130 | ] 1131 | }, 1132 | { 1133 | "Name": "Moray", 1134 | "ISBNs": [ 1135 | "9780747532743", 1136 | "9780141187761", 1137 | "9780141439518", 1138 | "9780521618748", 1139 | "9780007371464" 1140 | ] 1141 | }, 1142 | { 1143 | "Name": "Castell-nedd Port Talbot - Neath Port Talbot", 1144 | "ISBNs": [ 1145 | "9780747532743", 1146 | "9780141187761", 1147 | "9780141439518", 1148 | "9780140707342", 1149 | "9780007371464" 1150 | ] 1151 | }, 1152 | { 1153 | "Name": "Newcastle upon Tyne", 1154 | "ISBNs": [ 1155 | "9780747532743", 1156 | "9780141187761", 1157 | "9780141439518", 1158 | "9780198320494", 1159 | "9780007371464" 1160 | ] 1161 | }, 1162 | { 1163 | "Name": "Newham", 1164 | "ISBNs": [ 1165 | "9780747532743", 1166 | "9780141187761", 1167 | "9780141439518", 1168 | "9780521618748", 1169 | "9780007371464" 1170 | ] 1171 | }, 1172 | { 1173 | "Name": "Casnewydd - Newport", 1174 | "ISBNs": [ 1175 | "9781408855652", 1176 | "9780141187761", 1177 | "9780141439518", 1178 | "9780192814487", 1179 | "9780007371464" 1180 | ] 1181 | }, 1182 | { 1183 | "Name": "Norfolk", 1184 | "ISBNs": [ 1185 | "9780747554561", 1186 | "9780141187761", 1187 | "9780141439518", 1188 | "9780521532525", 1189 | "9780007371464" 1190 | ] 1191 | }, 1192 | { 1193 | "Name": "North Ayrshire", 1194 | "ISBNs": [ 1195 | "9780747532743", 1196 | "9780140126716", 1197 | "9780140430721", 1198 | "9780007208319", 1199 | "9780007371464" 1200 | ] 1201 | }, 1202 | { 1203 | "Name": "North East Lincolnshire", 1204 | "ISBNs": [ 1205 | "9780747554561", 1206 | "9780141187761", 1207 | "9781593080204", 1208 | "9780198117476", 1209 | "9780007371464" 1210 | ] 1211 | }, 1212 | { 1213 | "Name": "North Lanarkshire", 1214 | "ISBNs": [ 1215 | "9781408855652", 1216 | "9780141036144", 1217 | "9780141439518", 1218 | "9780198321071", 1219 | "9780007371464" 1220 | ] 1221 | }, 1222 | { 1223 | "Name": "North Lincolnshire", 1224 | "ISBNs": [ 1225 | "9780747532743", 1226 | "9780141036144", 1227 | "9780141439518", 1228 | "9780140707342", 1229 | "9780007371464" 1230 | ] 1231 | }, 1232 | { 1233 | "Name": "North Somerset", 1234 | "ISBNs": [ 1235 | "9780747532743", 1236 | "9780141187761", 1237 | "9780553213102", 1238 | "9780521618748", 1239 | "9780007371464" 1240 | ] 1241 | }, 1242 | { 1243 | "Name": "North Tyneside", 1244 | "ISBNs": [ 1245 | "9780747532743", 1246 | "9780141187761", 1247 | "9780099589334", 1248 | "9780521618748", 1249 | "9780007371464" 1250 | ] 1251 | }, 1252 | { 1253 | "Name": "North Yorkshire", 1254 | "ISBNs": [ 1255 | "9780747554561", 1256 | "9780141187761", 1257 | "9781853260001", 1258 | "9780141013077", 1259 | "9780007371464" 1260 | ] 1261 | }, 1262 | { 1263 | "Name": "North Northamptonshire", 1264 | "ISBNs": [ 1265 | "9780747532743", 1266 | "9780141187761", 1267 | "9780141439518", 1268 | "9780521618748", 1269 | "9780007371464" 1270 | ] 1271 | }, 1272 | { 1273 | "Name": "Northumberland", 1274 | "ISBNs": [ 1275 | "9780747532743", 1276 | "9780241341650", 1277 | "9780141439518", 1278 | "9780141396507", 1279 | "9780008262204" 1280 | ] 1281 | }, 1282 | { 1283 | "Name": "Nottingham City", 1284 | "ISBNs": [ 1285 | "9780747532743", 1286 | "9780141187761", 1287 | "9780141439518", 1288 | "9780521618748", 1289 | "9780007371464" 1290 | ] 1291 | }, 1292 | { 1293 | "Name": "Nottinghamshire", 1294 | "ISBNs": [ 1295 | "9780747532743", 1296 | "9780141187761", 1297 | "9780141439518", 1298 | "9780521618748", 1299 | "9780007371464" 1300 | ] 1301 | }, 1302 | { 1303 | "Name": "Northern Ireland", 1304 | "ISBNs": [ 1305 | "9780747554561", 1306 | "9780141187761", 1307 | "9780141439518", 1308 | "9780140620580", 1309 | "9780007371464" 1310 | ] 1311 | }, 1312 | { 1313 | "Name": "Oldham", 1314 | "ISBNs": [ 1315 | "9781408855652", 1316 | "9780141187761", 1317 | "9780141439518", 1318 | "9781101100349", 1319 | "9780007371464" 1320 | ] 1321 | }, 1322 | { 1323 | "Name": "Orkney Islands", 1324 | "ISBNs": [ 1325 | "9780747532743", 1326 | "9780140278774", 1327 | "9780192833556", 1328 | "9780521293662", 1329 | "9780007371464" 1330 | ] 1331 | }, 1332 | { 1333 | "Name": "Oxfordshire", 1334 | "ISBNs": [ 1335 | "9780747532743", 1336 | "9780141187761", 1337 | "9780141439518", 1338 | "9781904271338", 1339 | "9780007371464" 1340 | ] 1341 | }, 1342 | { 1343 | "Name": "Sir Benfro - Pembrokeshire", 1344 | "ISBNs": [ 1345 | "9780747532743", 1346 | "9780141187761", 1347 | "9780486440910", 1348 | "9781909621862", 1349 | "9780007371464" 1350 | ] 1351 | }, 1352 | { 1353 | "Name": "Perth and Kinross", 1354 | "ISBNs": [ 1355 | "9780747532743", 1356 | "9780141187761", 1357 | "9780141439518", 1358 | "9780140620580", 1359 | "9780007371464" 1360 | ] 1361 | }, 1362 | { 1363 | "Name": "Peterborough", 1364 | "ISBNs": [ 1365 | "9780747532743", 1366 | "9780141391700", 1367 | "9780099589334", 1368 | "9781904271338", 1369 | "9780007371464" 1370 | ] 1371 | }, 1372 | { 1373 | "Name": "Plymouth", 1374 | "ISBNs": [ 1375 | "9781408855652", 1376 | "9780141393049", 1377 | "9781593080204", 1378 | "9780140707342", 1379 | "9780007371464" 1380 | ] 1381 | }, 1382 | { 1383 | "Name": "Poole", 1384 | "ISBNs": [ 1385 | "9780747532743", 1386 | "9780141187761", 1387 | "9780553213102", 1388 | "9780521618748", 1389 | "9780007371464" 1390 | ] 1391 | }, 1392 | { 1393 | "Name": "Portsmouth", 1394 | "ISBNs": [ 1395 | "9780747532743", 1396 | "9780141187761", 1397 | "9780141439518", 1398 | "9781904271338", 1399 | "9780007371464" 1400 | ] 1401 | }, 1402 | { 1403 | "Name": "Powys", 1404 | "ISBNs": [ 1405 | "9780747554561", 1406 | "9780141187761", 1407 | "9780099511151", 1408 | "9780198319856", 1409 | "9780007371464" 1410 | ] 1411 | }, 1412 | { 1413 | "Name": "Reading", 1414 | "ISBNs": [ 1415 | "9780747573609", 1416 | "9780141036144", 1417 | "9780099511151", 1418 | "9780140707342", 1419 | "9780007371464" 1420 | ] 1421 | }, 1422 | { 1423 | "Name": "Redbridge", 1424 | "ISBNs": [ 1425 | "9780747532743", 1426 | "9780141187761", 1427 | "9780141439518", 1428 | "9780521618748", 1429 | "9780007371464" 1430 | ] 1431 | }, 1432 | { 1433 | "Name": "Redcar and Cleveland", 1434 | "ISBNs": [ 1435 | "9780747532743", 1436 | "9780141393049", 1437 | "9780141439518", 1438 | "9780174434696", 1439 | "9780007371464" 1440 | ] 1441 | }, 1442 | { 1443 | "Name": "Renfrewshire", 1444 | "ISBNs": [ 1445 | "9780747532743", 1446 | "9780141187761", 1447 | "9780755331468", 1448 | "9780582784314", 1449 | "9780007371464" 1450 | ] 1451 | }, 1452 | { 1453 | "Name": "Rhondda Cynon Taf", 1454 | "ISBNs": [ 1455 | "9780747532743", 1456 | "9780141187761", 1457 | "9780141439518", 1458 | "9781853260094", 1459 | "9780007371464" 1460 | ] 1461 | }, 1462 | { 1463 | "Name": "Richmond upon Thames", 1464 | "ISBNs": [ 1465 | "9780747573609", 1466 | "9780141187761", 1467 | "9780099511151", 1468 | "9780521618748", 1469 | "9780007371464" 1470 | ] 1471 | }, 1472 | { 1473 | "Name": "Rochdale", 1474 | "ISBNs": [ 1475 | "9780747532743", 1476 | "9780141187761", 1477 | "9780141439518", 1478 | "9780198320494", 1479 | "9780007371464" 1480 | ] 1481 | }, 1482 | { 1483 | "Name": "Rotherham", 1484 | "ISBNs": [ 1485 | "9780747554561", 1486 | "9780141187761", 1487 | "9780099511151", 1488 | "9780174434696", 1489 | "9780007371464" 1490 | ] 1491 | }, 1492 | { 1493 | "Name": "Rutland", 1494 | "ISBNs": [ 1495 | "9781408855652", 1496 | "9780141187761", 1497 | "9780141439518", 1498 | "9781904271338", 1499 | "9780007371464" 1500 | ] 1501 | }, 1502 | { 1503 | "Name": "Salford", 1504 | "ISBNs": [ 1505 | "9781408855652", 1506 | "9780141187761", 1507 | "9780141439518", 1508 | "9781853260094", 1509 | "9780007371464" 1510 | ] 1511 | }, 1512 | { 1513 | "Name": "Sandwell", 1514 | "ISBNs": [ 1515 | "9780747532743", 1516 | "9780141187761", 1517 | "9780141439518", 1518 | "9781853260094", 1519 | "9780007371464" 1520 | ] 1521 | }, 1522 | { 1523 | "Name": "Scottish Borders", 1524 | "ISBNs": [ 1525 | "9780747532743", 1526 | "9780141036144", 1527 | "9780140430721", 1528 | "9780486272788", 1529 | "9780007371464" 1530 | ] 1531 | }, 1532 | { 1533 | "Name": "Sefton", 1534 | "ISBNs": [ 1535 | "9781408855652", 1536 | "9780141036144", 1537 | "9780141439518", 1538 | "9780521434942", 1539 | "9780007371464" 1540 | ] 1541 | }, 1542 | { 1543 | "Name": "Sheffield", 1544 | "ISBNs": [ 1545 | "9780747532743", 1546 | "9780141187761", 1547 | "9780141439518", 1548 | "9780140620580", 1549 | "9780007371464" 1550 | ] 1551 | }, 1552 | { 1553 | "Name": "Shetland Islands", 1554 | "ISBNs": [ 1555 | "9780747532743", 1556 | "9780141187761", 1557 | "9780755331468", 1558 | "9781903436677", 1559 | "9780007371464" 1560 | ] 1561 | }, 1562 | { 1563 | "Name": "Shropshire", 1564 | "ISBNs": [ 1565 | "9780747532743", 1566 | "9780141187761", 1567 | "9780141439518", 1568 | "9780521532525", 1569 | "9780007371464" 1570 | ] 1571 | }, 1572 | { 1573 | "Name": "Slough", 1574 | "ISBNs": [ 1575 | "9781408855652", 1576 | "9780141036144", 1577 | "9780141439518", 1578 | "9780230217874", 1579 | "9780007371464" 1580 | ] 1581 | }, 1582 | { 1583 | "Name": "Solihull", 1584 | "ISBNs": [ 1585 | "9781408855652", 1586 | "9780679417392", 1587 | "9780141439518", 1588 | "9781904271338", 1589 | "9780007371464" 1590 | ] 1591 | }, 1592 | { 1593 | "Name": "Somerset", 1594 | "ISBNs": [ 1595 | "9780747532743", 1596 | "9780141187761", 1597 | "9780553213102", 1598 | "9780521618748", 1599 | "9780007371464" 1600 | ] 1601 | }, 1602 | { 1603 | "Name": "South Ayrshire", 1604 | "ISBNs": [ 1605 | "9780590353427", 1606 | "9780141187761", 1607 | "9780141040349", 1608 | "9780140620580", 1609 | "9780007371464" 1610 | ] 1611 | }, 1612 | { 1613 | "Name": "South Gloucestershire", 1614 | "ISBNs": [ 1615 | "9780747532743", 1616 | "9780141187761", 1617 | "9780553213102", 1618 | "9780521618748", 1619 | "9780007371464" 1620 | ] 1621 | }, 1622 | { 1623 | "Name": "South Lanarkshire", 1624 | "ISBNs": [ 1625 | "9780747532743", 1626 | "9780141187761", 1627 | "9780141439518", 1628 | "9780140707342", 1629 | "9780007371464" 1630 | ] 1631 | }, 1632 | { 1633 | "Name": "South Tyneside", 1634 | "ISBNs": [ 1635 | "9780747532743", 1636 | "9780141187761", 1637 | "9780141439518", 1638 | "9780141013077", 1639 | "9780007371464" 1640 | ] 1641 | }, 1642 | { 1643 | "Name": "Southampton", 1644 | "ISBNs": [ 1645 | "9780747554561", 1646 | "9780141036144", 1647 | "9780141439518", 1648 | "9780141013077", 1649 | "9780007371464" 1650 | ] 1651 | }, 1652 | { 1653 | "Name": "Southend on Sea", 1654 | "ISBNs": [ 1655 | "9781408855652", 1656 | "9780141036144", 1657 | "9780141439518", 1658 | "9781904271338", 1659 | "9780007371464" 1660 | ] 1661 | }, 1662 | { 1663 | "Name": "Southwark", 1664 | "ISBNs": [ 1665 | "9780747532743", 1666 | "9780141187761", 1667 | "9780141439518", 1668 | "9781853260094", 1669 | "9780007371464" 1670 | ] 1671 | }, 1672 | { 1673 | "Name": "St Helens", 1674 | "ISBNs": [ 1675 | "9780747532743", 1676 | "9780141187761", 1677 | "9780141439518", 1678 | "9780192834164", 1679 | "9780007371464" 1680 | ] 1681 | }, 1682 | { 1683 | "Name": "Staffordshire", 1684 | "ISBNs": [ 1685 | "9780747532743", 1686 | "9780141187761", 1687 | "9780141439518", 1688 | "9780140707342", 1689 | "9780007371464" 1690 | ] 1691 | }, 1692 | { 1693 | "Name": "Stirling", 1694 | "ISBNs": [ 1695 | "9781408855652", 1696 | "9780141187761", 1697 | "9780141439518", 1698 | "9780708945001", 1699 | "9780007371464" 1700 | ] 1701 | }, 1702 | { 1703 | "Name": "Stockport", 1704 | "ISBNs": [ 1705 | "9781408855652", 1706 | "9780141187761", 1707 | "9780141439518", 1708 | "9780521434942", 1709 | "9780007371464" 1710 | ] 1711 | }, 1712 | { 1713 | "Name": "Stockton on Tees", 1714 | "ISBNs": [ 1715 | "9781408855652", 1716 | "9780141187761", 1717 | "9780141439518", 1718 | "9780521618748", 1719 | "9780007371464" 1720 | ] 1721 | }, 1722 | { 1723 | "Name": "Stoke on Trent", 1724 | "ISBNs": [ 1725 | "9780747532743", 1726 | "9780141187761", 1727 | "9780192547026", 1728 | "9780416179200", 1729 | "9780007371464" 1730 | ] 1731 | }, 1732 | { 1733 | "Name": "Suffolk", 1734 | "ISBNs": [ 1735 | "9780747532743", 1736 | "9780141187761", 1737 | "9780140434262", 1738 | "9780521618748", 1739 | "9780007371464" 1740 | ] 1741 | }, 1742 | { 1743 | "Name": "Sunderland", 1744 | "ISBNs": [ 1745 | "9781408855652", 1746 | "9780141187761", 1747 | "9780141439518", 1748 | "9780174434696", 1749 | "9780007371464" 1750 | ] 1751 | }, 1752 | { 1753 | "Name": "Surrey", 1754 | "ISBNs": [ 1755 | "9780747532743", 1756 | "9780141187761", 1757 | "9780141439518", 1758 | "9780521618748", 1759 | "9780007371464" 1760 | ] 1761 | }, 1762 | { 1763 | "Name": "Sutton", 1764 | "ISBNs": [ 1765 | "9780747532743", 1766 | "9781405807043", 1767 | "9781405862462", 1768 | "9781904271338", 1769 | "9780007371464" 1770 | ] 1771 | }, 1772 | { 1773 | "Name": "Abertawe - Swansea", 1774 | "ISBNs": [ 1775 | "9780747532743", 1776 | "9780141036144", 1777 | "9780141439518", 1778 | "9781904271338", 1779 | "9780007371464" 1780 | ] 1781 | }, 1782 | { 1783 | "Name": "Swindon", 1784 | "ISBNs": [ 1785 | "9780747532743", 1786 | "9780141187761", 1787 | "9780192833556", 1788 | "9780521618748", 1789 | "9780007371464" 1790 | ] 1791 | }, 1792 | { 1793 | "Name": "Tameside", 1794 | "ISBNs": [ 1795 | "9780747554561", 1796 | "9780141393049", 1797 | "9780141199078", 1798 | "9780140707342", 1799 | "9780007371464" 1800 | ] 1801 | }, 1802 | { 1803 | "Name": "Telford and Wrekin", 1804 | "ISBNs": [ 1805 | "9780747532743", 1806 | "9780141187761", 1807 | "9781407158518", 1808 | "9781904271338", 1809 | "9780007371464" 1810 | ] 1811 | }, 1812 | { 1813 | "Name": "Thurrock", 1814 | "ISBNs": [ 1815 | "9780747532743", 1816 | "9780141187761", 1817 | "9780141439518", 1818 | "9780521618748", 1819 | "9780007371464" 1820 | ] 1821 | }, 1822 | { 1823 | "Name": "Torbay", 1824 | "ISBNs": [ 1825 | "9780747532743", 1826 | "9780141187358", 1827 | "9780141439518", 1828 | "9780750030007", 1829 | "9780007371464" 1830 | ] 1831 | }, 1832 | { 1833 | "Name": "Tor-faen - Torfaen", 1834 | "ISBNs": [ 1835 | "9780747532743", 1836 | "9780141187761", 1837 | "9780141439518", 1838 | "9780140707342", 1839 | "9780007371464" 1840 | ] 1841 | }, 1842 | { 1843 | "Name": "Tower Hamlets", 1844 | "ISBNs": [ 1845 | "9780747532743", 1846 | "9780141187761", 1847 | "9780141439518", 1848 | "9780521618748", 1849 | "9780007371464" 1850 | ] 1851 | }, 1852 | { 1853 | "Name": "Trafford", 1854 | "ISBNs": [ 1855 | "9781408855652", 1856 | "9780141187761", 1857 | "9780141439518", 1858 | "9780141013077", 1859 | "9780007371464" 1860 | ] 1861 | }, 1862 | { 1863 | "Name": "Bro Morgannwg - the Vale of Glamorgan", 1864 | "ISBNs": [ 1865 | "9780747532743", 1866 | "9780141187761", 1867 | "9780099511151", 1868 | "9780486272788", 1869 | "9780007371464" 1870 | ] 1871 | }, 1872 | { 1873 | "Name": "Wakefield", 1874 | "ISBNs": [ 1875 | "9780747554561", 1876 | "9780141187761", 1877 | "9780141439518", 1878 | "9780486272788", 1879 | "9780007371464" 1880 | ] 1881 | }, 1882 | { 1883 | "Name": "Walsall", 1884 | "ISBNs": [ 1885 | "9781408855652", 1886 | "9780141187761", 1887 | "9780141439518", 1888 | "9780486272788", 1889 | "9780007371464" 1890 | ] 1891 | }, 1892 | { 1893 | "Name": "Waltham Forest", 1894 | "ISBNs": [ 1895 | "9781408855652", 1896 | "9780141187761", 1897 | "9780708982280", 1898 | "9781904271338", 1899 | "9780007371464" 1900 | ] 1901 | }, 1902 | { 1903 | "Name": "Wandsworth", 1904 | "ISBNs": [ 1905 | "9780747532743", 1906 | "9780141036144", 1907 | "9780141439518", 1908 | "9780521618748", 1909 | "9780007371464" 1910 | ] 1911 | }, 1912 | { 1913 | "Name": "Warrington", 1914 | "ISBNs": [ 1915 | "9780747532743", 1916 | "9780141187761", 1917 | "9780141439518", 1918 | "9780521532525", 1919 | "9780007371464" 1920 | ] 1921 | }, 1922 | { 1923 | "Name": "Warwickshire", 1924 | "ISBNs": [ 1925 | "9781408855652", 1926 | "9780141187761", 1927 | "9780141439518", 1928 | "9780521618748", 1929 | "9780007371464" 1930 | ] 1931 | }, 1932 | { 1933 | "Name": "West Berkshire", 1934 | "ISBNs": [ 1935 | "9780747532743", 1936 | "9780141187761", 1937 | "9780141439518", 1938 | "9781101100349", 1939 | "9780007371464" 1940 | ] 1941 | }, 1942 | { 1943 | "Name": "West Dunbartonshire", 1944 | "ISBNs": [ 1945 | "9780747532743", 1946 | "9780141187761", 1947 | "9780141439518", 1948 | "9780141013077", 1949 | "9780007371464" 1950 | ] 1951 | }, 1952 | { 1953 | "Name": "West Lothian", 1954 | "ISBNs": [ 1955 | "9781408855652", 1956 | "9780141187761", 1957 | "9780141439518", 1958 | "9780140707342", 1959 | "9780007371464" 1960 | ] 1961 | }, 1962 | { 1963 | "Name": "West Sussex", 1964 | "ISBNs": [ 1965 | "9780747532743", 1966 | "9780141187761", 1967 | "9780099589334", 1968 | "9781472518385", 1969 | "9780007371464" 1970 | ] 1971 | }, 1972 | { 1973 | "Name": "West Northamptonshire", 1974 | "ISBNs": [ 1975 | "9780747532743", 1976 | "9780141187761", 1977 | "9780141439518", 1978 | "9780521618748", 1979 | "9780007371464" 1980 | ] 1981 | }, 1982 | { 1983 | "Name": "Western Isles", 1984 | "ISBNs": [ 1985 | "9781408810545", 1986 | "9780435176709", 1987 | "9780141439518", 1988 | "9780955285615", 1989 | "9780007371464" 1990 | ] 1991 | }, 1992 | { 1993 | "Name": "Westminster", 1994 | "ISBNs": [ 1995 | "9780747532743", 1996 | "9780141187761", 1997 | "9780141439518", 1998 | "9780140707342", 1999 | "9780007371464" 2000 | ] 2001 | }, 2002 | { 2003 | "Name": "Westmorland and Furness", 2004 | "ISBNs": [ 2005 | "9780747532743", 2006 | "9780141187761", 2007 | "9780141439518", 2008 | "9780140707342", 2009 | "9780007371464" 2010 | ] 2011 | }, 2012 | { 2013 | "Name": "Wigan", 2014 | "ISBNs": [ 2015 | "9780747532743", 2016 | "9780141036144", 2017 | "9780141439518", 2018 | "9781904271338", 2019 | "9780007371464" 2020 | ] 2021 | }, 2022 | { 2023 | "Name": "Wiltshire", 2024 | "ISBNs": [ 2025 | "9780747532743", 2026 | "9780141187761", 2027 | "9780141439518", 2028 | "9781904271338", 2029 | "9780007371464" 2030 | ] 2031 | }, 2032 | { 2033 | "Name": "Windsor & Maidenhead", 2034 | "ISBNs": [ 2035 | "9781408855652", 2036 | "9780141036144", 2037 | "9780141439518", 2038 | "9780192834164", 2039 | "9780007371464" 2040 | ] 2041 | }, 2042 | { 2043 | "Name": "Wirral", 2044 | "ISBNs": [ 2045 | "9780747532743", 2046 | "9780141393049", 2047 | "9780141439518", 2048 | "9780140620580", 2049 | "9780007371464" 2050 | ] 2051 | }, 2052 | { 2053 | "Name": "Wokingham", 2054 | "ISBNs": [ 2055 | "9780747532743", 2056 | "9780141187761", 2057 | "9780141439518", 2058 | "9780174434696", 2059 | "9780007371464" 2060 | ] 2061 | }, 2062 | { 2063 | "Name": "Wolverhampton", 2064 | "ISBNs": [ 2065 | "9780747532743", 2066 | "9780141187761", 2067 | "9780141439518", 2068 | "9781904271338", 2069 | "9780007371464" 2070 | ] 2071 | }, 2072 | { 2073 | "Name": "Worcestershire", 2074 | "ISBNs": [ 2075 | "9780747532743", 2076 | "9780141187761", 2077 | "9780141439518", 2078 | "9780521618748", 2079 | "9780007371464" 2080 | ] 2081 | }, 2082 | { 2083 | "Name": "Wrecsam - Wrexham", 2084 | "ISBNs": [ 2085 | "9780747532743", 2086 | "9780141187761", 2087 | "9780141439518", 2088 | "9780140707342", 2089 | "9780007371464" 2090 | ] 2091 | }, 2092 | { 2093 | "Name": "York City", 2094 | "ISBNs": [ 2095 | "9780747532743", 2096 | "9780141187761", 2097 | "9780141199078", 2098 | "9780141013077", 2099 | "9780007371464" 2100 | ] 2101 | } 2102 | ] 2103 | --------------------------------------------------------------------------------