├── project ├── SECURITY.md └── INFRA.md ├── README.md ├── app ├── refresh.js ├── server.js ├── notification.js ├── email.js ├── locations.js └── availabilities.js ├── package.json ├── data └── ca-province.json ├── LICENSE ├── util └── migrate.js ├── .gitignore └── public └── index.html /project/SECURITY.md: -------------------------------------------------------------------------------- 1 | [ ] - Remove mosh 2 | [ ] - Ensure sshd configured for public key auth only (no password) 3 | [ ] - Ensure sshd configured to not allow remote root login 4 | -------------------------------------------------------------------------------- /project/INFRA.md: -------------------------------------------------------------------------------- 1 | [ ] - Dockerize the nodejs app 2 | [ ] - Stand up mongodb to store data 3 | [ ] - Implement backup process 4 | [ ] - Investigate NodeJS PM2 for process supervision 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ikea-monitor 2 | 3 | **June 17, 2020 | Code here hasn't been updated for days since I have moved the code to AWS serverless stack and haven't figured out how to make git and AWS Lambda work together seamlessly. I will sync everything as soon as I can.** 4 | 5 | IKEA Click & Collect Availability website source code 6 | 7 | This is a fun project for myself to get used to web development again after 5 years of absence. 8 | 9 | For new devleopers who want to learn about web scraping, some code in this project shows a good example. 10 | 11 | For coders who want to contribute, please join the discord server [here](https://discord.gg/csjgU5V). 12 | -------------------------------------------------------------------------------- /app/refresh.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { promisify } = require('util'); 4 | const sleep = promisify(setTimeout); 5 | 6 | const { MongoClient } = require('mongodb'); 7 | 8 | const locations = require('./locations.js'); 9 | const availabilities = require('./availabilities.js'); 10 | const notification = require('./notification.js'); 11 | 12 | async function main() { 13 | const uri = process.env.MONGODB_URI; 14 | const dbClient = new MongoClient(uri); 15 | 16 | try { 17 | await dbClient.connect(); 18 | const db = dbClient.db('ikeaMonitor'); 19 | 20 | locations.watch(db); 21 | availabilities.watch(db); 22 | // notification.watch(db); 23 | } catch (error) { 24 | console.error(error); 25 | } 26 | } 27 | 28 | main(); 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ikea-monitor", 3 | "version": "1.0.0", 4 | "description": "IKEA Click & Collect Availability website source code", 5 | "main": "", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/richarddong/ikea-monitor.git" 12 | }, 13 | "author": "Shaotian Dong", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/richarddong/ikea-monitor/issues" 17 | }, 18 | "homepage": "https://github.com/richarddong/ikea-monitor#readme", 19 | "dependencies": { 20 | "@sendgrid/mail": "^7.1.1", 21 | "body-parser": "^1.19.0", 22 | "connect": "^3.7.0", 23 | "lodash": "^4.17.15", 24 | "mongodb": "^3.5.8", 25 | "serve-static": "^1.14.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /data/ca-province.json: -------------------------------------------------------------------------------- 1 | { 2 | "Bathurst": "NB", 3 | "Boucherville": "QC", 4 | "Burlington": "ON", 5 | "Calgary": "AB", 6 | "Charlottetown": "PE", 7 | "Coquitlam": "BC", 8 | "Halifax": "NS", 9 | "Drummondville": "QC", 10 | "Edmonton": "AB", 11 | "Etobicoke": "ON", 12 | "Fredericton": "NB", 13 | "Kelowna": "BC", 14 | "Kingston": "ON", 15 | "London": "ON", 16 | "Moncton": "NB", 17 | "Montreal": "QC", 18 | "Nanaimo": "BC", 19 | "New Glasgow": "NS", 20 | "North York": "ON", 21 | "Ottawa": "ON", 22 | "Québec": "QC", 23 | "Regina": "SK", 24 | "Richmond": "BC", 25 | "Saanichton": "BC", 26 | "Saint John": "NB", 27 | "Saskatoon": "SK", 28 | "Sherbrooke": "QC", 29 | "St. John's": "NL", 30 | "Sydney": "NS", 31 | "Toronto": "ON", 32 | "Trois-Rivières": "QC", 33 | "Vaughan": "ON", 34 | "Windsor": "ON", 35 | "Winnipeg": "MB" 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 richarddong 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 | -------------------------------------------------------------------------------- /util/migrate.js: -------------------------------------------------------------------------------- 1 | const { MongoClient } = require('mongodb'); 2 | 3 | async function main() { 4 | const uri = process.env.MONGODB_URI; 5 | const dbClient = new MongoClient(uri); 6 | 7 | try { 8 | await dbClient.connect(); 9 | const db = dbClient.db('test'); 10 | 11 | const subscribers = require('../temp/subscribers.json'); 12 | 13 | const allUpdates = []; 14 | 15 | for (const email in subscribers) { 16 | const locationNames = Array.isArray(subscribers[email]) ? subscribers[email] : [subscribers[email]]; 17 | for (const locationName of locationNames) { 18 | allUpdates.push(db.collection('locations') 19 | .updateOne( 20 | {name: locationName}, 21 | { 22 | $addToSet: {subscribers: email.trim()} 23 | } 24 | ) 25 | ); 26 | } 27 | } 28 | 29 | console.log(allUpdates.length); 30 | await Promise.all(allUpdates); 31 | 32 | } catch (error) { 33 | console.error(error); 34 | } finally { 35 | await dbClient.close(); 36 | } 37 | } 38 | 39 | main(); 40 | -------------------------------------------------------------------------------- /app/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { MongoClient } = require('mongodb'); 4 | const http = require('http'); 5 | const connect = require('connect'); 6 | const bodyParser = require('body-parser'); 7 | const serveStatic = require('serve-static'); 8 | const finalhandler = require('finalhandler'); 9 | 10 | const notification = require('./notification.js'); 11 | 12 | async function main() { 13 | const uri = process.env.MONGODB_URI; 14 | const dbClient = new MongoClient(uri); 15 | 16 | try { 17 | await dbClient.connect(); 18 | const db = dbClient.db('ikeaMonitor'); 19 | const app = connect(); 20 | 21 | app.use('/latest.json', function (req, res) { 22 | res.setHeader('Content-Type', 'application/json; charset=UTF-8'); 23 | db.collection('locations') 24 | .find({}) 25 | .sort({country: 1, state: 1, name: 1}) 26 | .project({subscribers: 0}) 27 | .toArray((error, latest) => { 28 | res.end(JSON.stringify(latest, null, 2)); 29 | }); 30 | }); 31 | 32 | app.use(bodyParser.urlencoded({extended: false})); 33 | 34 | app.use('/subscribe', function (req, res) { 35 | let storeNames; 36 | if (typeof req.body['storeNames[]'] == 'string') { 37 | storeNames = [req.body['storeNames[]']]; 38 | } else { 39 | storeNames = req.body['storeNames[]']; 40 | } 41 | notification.subscribe(db, req.body.email, storeNames); 42 | res.end(); 43 | }); 44 | 45 | app.use('/unsubscribe_all', function (req, res) { 46 | notification.unsubscribeAll(db, req.body.email); 47 | res.end(); 48 | }); 49 | 50 | const serve = serveStatic('../public', { 51 | 'index': ['index.html', 'index.htm'], 52 | 'maxAge': '1m' 53 | }); 54 | 55 | app.use(function (req, res) { 56 | serve(req, res, finalhandler(req, res)); 57 | }); 58 | 59 | http.createServer(app).listen(3000); 60 | 61 | } catch (error) { 62 | console.error(error); 63 | } finally { 64 | // await dbClient.close(); 65 | } 66 | } 67 | 68 | main(); 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | public/*.json 2 | 3 | # Created by https://www.gitignore.io/api/node,macos 4 | # Edit at https://www.gitignore.io/?templates=node,macos 5 | 6 | ### macOS ### 7 | # General 8 | .DS_Store 9 | .AppleDouble 10 | .LSOverride 11 | 12 | # Icon must end with two \r 13 | Icon 14 | 15 | # Thumbnails 16 | ._* 17 | 18 | # Files that might appear in the root of a volume 19 | .DocumentRevisions-V100 20 | .fseventsd 21 | .Spotlight-V100 22 | .TemporaryItems 23 | .Trashes 24 | .VolumeIcon.icns 25 | .com.apple.timemachine.donotpresent 26 | 27 | # Directories potentially created on remote AFP share 28 | .AppleDB 29 | .AppleDesktop 30 | Network Trash Folder 31 | Temporary Items 32 | .apdisk 33 | 34 | ### Node ### 35 | # Logs 36 | logs 37 | *.log 38 | npm-debug.log* 39 | yarn-debug.log* 40 | yarn-error.log* 41 | lerna-debug.log* 42 | 43 | # Diagnostic reports (https://nodejs.org/api/report.html) 44 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 45 | 46 | # Runtime data 47 | pids 48 | *.pid 49 | *.seed 50 | *.pid.lock 51 | 52 | # Directory for instrumented libs generated by jscoverage/JSCover 53 | lib-cov 54 | 55 | # Coverage directory used by tools like istanbul 56 | coverage 57 | *.lcov 58 | 59 | # nyc test coverage 60 | .nyc_output 61 | 62 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 63 | .grunt 64 | 65 | # Bower dependency directory (https://bower.io/) 66 | bower_components 67 | 68 | # node-waf configuration 69 | .lock-wscript 70 | 71 | # Compiled binary addons (https://nodejs.org/api/addons.html) 72 | build/Release 73 | 74 | # Dependency directories 75 | node_modules/ 76 | jspm_packages/ 77 | 78 | # TypeScript v1 declaration files 79 | typings/ 80 | 81 | # TypeScript cache 82 | *.tsbuildinfo 83 | 84 | # Optional npm cache directory 85 | .npm 86 | 87 | # Optional eslint cache 88 | .eslintcache 89 | 90 | # Optional REPL history 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | *.tgz 95 | 96 | # Yarn Integrity file 97 | .yarn-integrity 98 | 99 | # dotenv environment variables file 100 | .env 101 | .env.test 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | .cache 105 | 106 | # next.js build output 107 | .next 108 | 109 | # nuxt.js build output 110 | .nuxt 111 | 112 | # rollup.js default build output 113 | dist/ 114 | 115 | # Uncomment the public line if your project uses Gatsby 116 | # https://nextjs.org/blog/next-9-1#public-directory-support 117 | # https://create-react-app.dev/docs/using-the-public-folder/#docsNav 118 | # public 119 | 120 | # Storybook build outputs 121 | .out 122 | .storybook-out 123 | 124 | # vuepress build output 125 | .vuepress/dist 126 | 127 | # Serverless directories 128 | .serverless/ 129 | 130 | # FuseBox cache 131 | .fusebox/ 132 | 133 | # DynamoDB Local files 134 | .dynamodb/ 135 | 136 | # Temporary folders 137 | tmp/ 138 | temp/ 139 | 140 | # End of https://www.gitignore.io/api/node,macos 141 | sendgrid.env 142 | -------------------------------------------------------------------------------- /app/notification.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { chunk } = require('lodash/array'); 4 | 5 | const email = require('./email.js'); 6 | 7 | async function subscribe(db, emailAddress, locationNames) { 8 | console.log('Subscribe: ', emailAddress, locationNames); 9 | const updates = locationNames.map(locationName => { 10 | return db.collection('locations') 11 | .updateOne( 12 | {name: locationName}, 13 | { 14 | $addToSet: {subscribers: emailAddress} 15 | } 16 | ); 17 | }); 18 | await Promise.all(updates); 19 | await email.send(emailAddress, 20 | email.subscriptionConfirmationMsg(locationNames)); 21 | return true; 22 | } 23 | 24 | async function unsubscribeAll(db, emailAddress) { 25 | console.log('Unsubscribe All: ', emailAddress); 26 | return db.collection('locations') 27 | .updateMany( 28 | {}, 29 | { 30 | $pull: {subscribers: emailAddress} 31 | } 32 | ); 33 | } 34 | 35 | // async function subscriptionsOf(db, emailAddress) { 36 | // return db.collection('locations') 37 | // .find({subscribers: emailAddress}) 38 | // .project({subscribers: 0}) 39 | // .toArray(); 40 | // } 41 | 42 | // async function subscribersOf(db, locationName) { 43 | // const location = await db.collection('locations') 44 | // .findOne({name: locationName}); 45 | // return location.subscribers; 46 | // } 47 | 48 | async function notify(db, location) { 49 | if (!location.subscribers || location.subscribers.length == 0) return; 50 | await db.collection('locations').updateOne({ 51 | country: location.country, 52 | state: location.state, 53 | name: location.name, 54 | id: location.id 55 | }, { 56 | $set: {lastNotified: new Date()} 57 | }); 58 | const subscribersChunks = chunk(location.subscribers, 900); 59 | return Promise.allSettled(subscribersChunks.map(subscribersChunk => { 60 | return email.send(subscribersChunk, email.notificationMsg(location.name)); 61 | })); 62 | } 63 | 64 | async function watch(db) { 65 | const pipeline = [ 66 | { 67 | '$match': { 68 | 'operationType': 'update', 69 | 'updateDescription.updatedFields.lastStatus': 'open' 70 | } 71 | } 72 | ]; 73 | const stream = db.collection('locations') 74 | .watch(pipeline, {fullDocument: 'updateLookup'}); 75 | stream.on('change', (locationChangeStreamRes) => { 76 | notify(db, locationChangeStreamRes.fullDocument); 77 | console.log(locationChangeStreamRes); 78 | }); 79 | } 80 | 81 | exports.subscribe = subscribe; 82 | exports.unsubscribeAll = unsubscribeAll; 83 | exports.watch = watch; 84 | 85 | // // Debugging Code 86 | 87 | // const { MongoClient } = require('mongodb'); 88 | 89 | // async function main() { 90 | // const uri = process.env.MONGODB_URI; 91 | // const dbClient = new MongoClient(uri); 92 | 93 | // try { 94 | // await dbClient.connect(); 95 | // const db = dbClient.db('ikeaMonitor'); 96 | 97 | // // await subscribe(db, 'test@dong.st', ['Burbank', 'Costa Mesa']); 98 | // // await subscribe(db, 'test2@dong.st', ['Burbank', 'Carson']); 99 | // // await subscribe(db, 'test3@dong.st', ['Burbank', 'Carson']); 100 | // // await unsubscribeAll(db, 'test3@dong.st'); 101 | // // console.log(await subscriptionsOf(db, 'test@dong.st')); 102 | // // console.log(await subscribersOf(db, 'Burbank')); 103 | // await watch(db); 104 | 105 | // } catch (error) { 106 | // console.error(error); 107 | // } finally { 108 | // // await dbClient.close(); 109 | // } 110 | // } 111 | 112 | // main(); 113 | -------------------------------------------------------------------------------- /app/email.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sgMail = require('@sendgrid/mail'); 4 | sgMail.setApiKey(process.env.SENDGRID_API_KEY); 5 | 6 | function subscriptionConfirmationMsg(locationNames) { 7 | return { 8 | from: 'IKEA Click & Collect Availability ', 9 | subject: `Subscription Confirmation`, 10 | html: ` 11 |

Hi, there!

12 |

You've subscribed to email notification of the latest availability at IKEA ${locationNames.join(', ')}. A single email will be sent to you whenever any of your locations open for new Click & Collect order.

13 |

To help yourself and other people receive email notification in the future, please kindly add the sender of this email to your contact and "report not spam" if this email is marked spam.

14 |

I wish your locations open soon and you get the amazing IKEA products you love.

15 |

If you like my service, please share it with your friend and buy me a coffee here, or donate on gofundme. Thank you. ;)

16 |

dongst

17 |
18 |

--------------------

19 |

This is a beta version of email notification from my website, IKEA Click & Collect Availability.

20 |

Do not reply to this email. Provide feedback here.

21 |

To unsubscribe, go to my website, type in your email address and click "Unsubscribe all".

22 |

This is not an email from IKEA. IKEA® is a registered trademark of Inter-IKEA Systems B.V. in the U.S. and other countries.

23 | `}; 24 | } 25 | 26 | function notificationMsg(locationName) { 27 | return { 28 | from: 'IKEA Click & Collect Availability ', 29 | subject: `IKEA ${locationName} Now Open for Click & Collect Orders`, 30 | html: ` 31 |

Good News! IKEA ${locationName} is now open for Click & Collect orders. Act fast!

32 |

Direct link to IKEA Homepage

33 |

To help yourself and other people receive email notification in the future, please kindly add the sender of this email to your contact and "report not spam" if this email is marked spam.

34 |

If you like my service, please share it with your friend and buy me a coffee here, or donate on gofundme. Thank you. ;)

35 |

dongst

36 |
37 |

--------------------

38 |

This is a beta version of email notification from my website, IKEA Click & Collect Availability.

39 |

Do not reply to this email. Provide feedback here.

40 |

To unsubscribe, go to my website, type in your email address and click "Unsubscribe all".

41 |

This is not an email from IKEA. IKEA® is a registered trademark of Inter-IKEA Systems B.V. in the U.S. and other countries.

42 | `}; 43 | } 44 | 45 | async function send(emailAddresses, message){ 46 | message.to = emailAddresses; 47 | return sgMail.sendMultiple(message); 48 | } 49 | 50 | exports.subscriptionConfirmationMsg = subscriptionConfirmationMsg; 51 | exports.notificationMsg = notificationMsg; 52 | exports.send = send; 53 | -------------------------------------------------------------------------------- /app/locations.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { promisify } = require('util'); 4 | const sleep = promisify(setTimeout); 5 | 6 | const https = require('https'); 7 | const caProvince = require('../data/ca-province.json'); 8 | 9 | async function getIkeaRaw(country) { 10 | return new Promise((resolve, reject) => { 11 | const req = https.get({ 12 | host: process.env.IKEA_HOST, 13 | port: 443, 14 | path: `https://ww8.ikea.com/clickandcollect/${country}` 15 | + '/receive/listfetchlocations?version=2', 16 | headers: { 'Host': 'ww8.ikea.com' }, 17 | timeout: 5000, 18 | }, (res) => { 19 | const { statusCode } = res; 20 | const contentType = res.headers['content-type']; 21 | 22 | let error; 23 | if (statusCode !== 200) { 24 | error = new Error('Request Failed.\n' + 25 | `Status Code: ${statusCode}`); 26 | } else if (!/^application\/json/.test(contentType)) { 27 | error = new Error('Invalid content-type.\n' + 28 | 'Expected application/json but received ' + 29 | contentType); 30 | } 31 | if (error) { 32 | // Consume response data to free up memory 33 | res.resume(); 34 | reject(error); 35 | return; 36 | } 37 | 38 | res.setEncoding('utf8'); 39 | let rawData = ''; 40 | res.on('data', (chunk) => { rawData += chunk; }); 41 | res.on('end', () => { 42 | try { 43 | const parsedData = JSON.parse(rawData); 44 | resolve(parsedData); 45 | } catch (e) { 46 | reject(e); 47 | } 48 | }); 49 | }).on('error', reject) 50 | .on('timeout', () => { 51 | req.abort(); 52 | reject(new Error('HTTP request timeout')); 53 | }); 54 | }); 55 | } 56 | 57 | function ikeaRaw2Locations(ikeaRaw, country) { 58 | const locations = []; 59 | switch (country) { 60 | case 'us': 61 | for (const id in ikeaRaw) { 62 | const rawLocation = ikeaRaw[id]; 63 | const location = {}; 64 | location.country = 'us'; 65 | const indexOfIKEA = rawLocation.name.indexOf('IKEA '); 66 | location.state = rawLocation.name.slice(indexOfIKEA - 4, indexOfIKEA - 2); 67 | location.name = rawLocation.name.slice(indexOfIKEA + 5); 68 | location.isClosed = rawLocation.isClosed; 69 | location.closingTimes = rawLocation.closingTimes; 70 | location.id = id; 71 | locations.push(location); 72 | } 73 | break; 74 | case 'ca': 75 | for (const id in ikeaRaw) { 76 | const rawLocation = ikeaRaw[id]; 77 | if (!rawLocation.name.startsWith('IKEA ')) continue; 78 | const name = rawLocation.name 79 | .slice(5, rawLocation.name.indexOf(' - ')); 80 | const state = caProvince[name]; 81 | const location = {}; 82 | location.country = 'ca'; 83 | location.state = state; 84 | location.name = name; 85 | location.isClosed = rawLocation.isClosed; 86 | location.closingTimes = rawLocation.closingTimes; 87 | location.id = id; 88 | locations.push(location); 89 | } 90 | break; 91 | } 92 | // locations.sort((a, b) => { if (a.name < b.name) return -1; }); 93 | // locations.sort((a, b) => { if (a.state < b.state) return -1; }); 94 | return locations; 95 | } 96 | 97 | async function get(country) { 98 | const ikeaRaw = await getIkeaRaw(country); 99 | const locations = ikeaRaw2Locations(ikeaRaw, country); 100 | return locations; 101 | } 102 | 103 | async function upsert(db, location) { 104 | const result = await db 105 | .collection('locations') 106 | .updateOne({ 107 | country: location.country, 108 | state: location.state, 109 | name: location.name, 110 | id: location.id 111 | }, { 112 | $set: location 113 | }, { 114 | upsert: true 115 | }); 116 | // console.debug(location); 117 | // console.debug(`${result.matchedCount} document(s) matched the query criteria.`); 118 | // if (result.upsertedCount > 0) { 119 | // console.debug(`One document was inserted with the id ${result.upsertedId._id}`); 120 | // } else { 121 | // console.debug(`${result.modifiedCount} document(s) was/were updated.`); 122 | // } 123 | return result; 124 | } 125 | 126 | async function upsertAll(db, locations) { 127 | const updates = []; 128 | for (const location of locations) { 129 | updates.push(upsert(db, location)); 130 | } 131 | return Promise.all(updates); 132 | } 133 | 134 | async function refresh(db, country) { 135 | if (country != 'us' && country != 'ca') { 136 | throw new Error(`Country expected 'us' or 'ca', got '${country}'`); 137 | } 138 | const locations = await get(country); 139 | return upsertAll(db, locations); 140 | } 141 | 142 | async function watch(db) { 143 | while (true) { 144 | try { 145 | await refresh(db, 'us'); 146 | console.log('U.S. updated'); 147 | } catch (error) { 148 | console.error(error.message); 149 | console.log(`Try U.S. later`); 150 | } 151 | await sleep(10000); 152 | try { 153 | await refresh(db, 'ca'); 154 | console.log('Canada updated'); 155 | } catch (error) { 156 | console.error(error.message); 157 | console.log(`Try Canada later`); 158 | } 159 | await sleep(10000); 160 | } 161 | } 162 | 163 | exports.watch = watch; 164 | 165 | // // Debugging Code 166 | 167 | // const { MongoClient } = require('mongodb'); 168 | 169 | // async function main() { 170 | // const uri = process.env.MONGODB_URI; 171 | // const dbClient = new MongoClient(uri); 172 | 173 | // try { 174 | // await dbClient.connect(); 175 | // const db = dbClient.db('ikeaMonitor'); 176 | // await refresh(db, 'us'); 177 | // await refresh(db, 'ca'); 178 | // console.debug('All updated'); 179 | // } catch (error) { 180 | // console.error(error); 181 | // } finally { 182 | // await dbClient.close(); 183 | // } 184 | // } 185 | 186 | // main(); 187 | -------------------------------------------------------------------------------- /app/availabilities.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { promisify } = require('util'); 4 | const sleep = promisify(setTimeout); 5 | 6 | const crypto = require('crypto'); 7 | const https = require('https'); 8 | const util = require('util'); 9 | 10 | function webForm(location) { 11 | let payload = ''; 12 | switch (location.country) { 13 | case 'us': 14 | payload = `{"selectedService":"fetchlocation",\ 15 | "customerView":"desktop",\ 16 | "locale":"en_US",\ 17 | "selectedServiceValue":"${location.id}",\ 18 | "slId":"1241241241",\ 19 | "articles":[{"articleNo":"20011408","count":1},\ 20 | {"articleNo":"30449908","count":1},\ 21 | {"articleNo":"40064702","count":1}]}`; 22 | break; 23 | case 'ca': 24 | payload = `{"selectedService":"fetchlocation",\ 25 | "customerView":"desktop",\ 26 | "locale":"en_CA",\ 27 | "selectedServiceValue":"${location.id}",\ 28 | "slId":"1241241241",\ 29 | "articles":[{"articleNo":"20011408","count":1},\ 30 | {"articleNo":"30449908","count":1},\ 31 | {"articleNo":"40064702","count":1}]}`; 32 | break; 33 | } 34 | 35 | const hash = crypto.createHmac('sha1', 'G6XxMY7n') 36 | .update(payload) 37 | .digest('hex'); 38 | 39 | return 'payload=' + payload + '&hmac=' + hash; 40 | } 41 | 42 | async function getIkeaRaw(location) { 43 | return new Promise(function (resolve, reject) { 44 | const req = https.request({ 45 | host: process.env.IKEA_HOST, 46 | port: 443, 47 | path: 'https://ww8.ikea.com/clickandcollect/' 48 | + `${location.country}/receive/`, 49 | method: 'POST', 50 | headers: { 51 | 'Content-Type': 'application/x-www-form-urlencoded', 52 | 'Host': 'ww8.ikea.com' 53 | }, 54 | timeout: 5000, 55 | }, (res) => { 56 | const { statusCode } = res; 57 | // const statusCode = 500; 58 | const contentType = res.headers['content-type']; 59 | 60 | let error; 61 | if (statusCode !== 200) { 62 | error = new Error('Request Failed.\n' + 63 | `Status Code: ${statusCode}`); 64 | } else if (!/^application\/json/.test(contentType)) { 65 | error = new Error('Invalid content-type.\n' + 66 | `Expected application/json but received ${contentType}`); 67 | } 68 | if (error) { 69 | // Consume response data to free up memory 70 | res.resume(); 71 | reject(error); 72 | return; 73 | } 74 | 75 | res.setEncoding('utf8'); 76 | let rawData = ''; 77 | res.on('data', (chunk) => { rawData += chunk; }); 78 | res.on('end', () => { 79 | try { 80 | const parsedData = JSON.parse(rawData); 81 | const now = new Date(res.headers['date']); 82 | resolve([parsedData, now]); 83 | } catch (error) { 84 | reject(error); 85 | } 86 | }); 87 | }).on('error', reject) 88 | .on('timeout', () => { 89 | req.abort(); 90 | reject(new Error('HTTP request timeout.')); 91 | }); 92 | 93 | req.write(webForm(location)); 94 | 95 | req.end(); 96 | }); 97 | } 98 | 99 | function ikeaRaw2Status (ikeaRaw) { 100 | const legitClosed1 = { 101 | status: 'ERROR', 102 | message: 'The commission capacity of this store is exhausted for today', 103 | code: 1470143968 104 | }; 105 | const legitClosed2 = { 106 | status: 'ERROR', 107 | message: 'Store has no available commissions', 108 | code: 1410693100 109 | }; 110 | const legitClosed3 = { 111 | status: 'ERROR', 112 | message: 'Tried 100 without success. Probably you have no handover or \ 113 | collection capacity set. Store in Charge: ', 114 | code: 0 115 | }; 116 | const legitClosed4 = { 117 | status: 'ERROR', 118 | message: 'No common capacity in configured time window', 119 | code: 1472475118 120 | }; 121 | if (util.isDeepStrictEqual(ikeaRaw, legitClosed1)) return 'closed'; 122 | if (util.isDeepStrictEqual(ikeaRaw, legitClosed2)) return 'closed'; 123 | if (ikeaRaw.status == legitClosed3.status 124 | && ikeaRaw.message.startsWith(legitClosed3.message) 125 | && ikeaRaw.code == legitClosed3.code) return 'closed'; 126 | if (util.isDeepStrictEqual(ikeaRaw, legitClosed4)) return 'closed'; 127 | if (ikeaRaw.status == 'OK') return 'open'; 128 | console.warn(ikeaRaw); 129 | throw new Error('Unhandled form of raw JSON of availability from IKEA.'); 130 | } 131 | 132 | async function get(location) { 133 | const [ikeaRaw, now] = await getIkeaRaw(location); 134 | const status = ikeaRaw2Status(ikeaRaw); 135 | return [status, now]; 136 | } 137 | 138 | async function update(db, location) { 139 | const [status, now] = await get(location); 140 | location.lastUpdated = now; 141 | if (status == 'open') location.lastOpen = now; 142 | else if (status == 'closed') location.lastClosed = now; 143 | location.lastStatus = status; 144 | const result = await db 145 | .collection('locations') 146 | .updateOne({ 147 | country: location.country, 148 | state: location.state, 149 | name: location.name, 150 | id: location.id 151 | }, { 152 | $set: location 153 | }); 154 | console.debug(`${result.matchedCount} document(s) matched the query criteria.`); 155 | if (result.upsertedCount > 0) { 156 | console.debug(`One document was inserted with the id ${result.upsertedId._id}`); 157 | } else { 158 | console.debug(`${result.modifiedCount} document(s) was/were updated.`); 159 | } 160 | return result; 161 | } 162 | 163 | async function watch(db) { 164 | while (true) { 165 | const allLocations = db.collection('locations') 166 | .find({}) 167 | .sort({lastUpdated: 1}); 168 | for await(const location of allLocations) { 169 | console.log(location.name); 170 | try { 171 | await update(db, location); 172 | } catch (error) { 173 | console.error(error.message); 174 | console.log(`Try ${location.name} later`); 175 | } 176 | await sleep(1000); 177 | } 178 | } 179 | } 180 | 181 | exports.watch = watch; 182 | 183 | // // Debugging Code 184 | 185 | // const { MongoClient } = require('mongodb'); 186 | 187 | // async function main() { 188 | // const uri = process.env.MONGODB_URI; 189 | // const dbClient = new MongoClient(uri); 190 | 191 | // try { 192 | // await dbClient.connect(); 193 | // const db = dbClient.db('ikeaMonitor'); 194 | // const cm = await db.collection('locations') 195 | // .findOne({ 196 | // name: 'Burbank', 197 | // state: 'CA' 198 | // }); 199 | // const result = await update(db, cm); 200 | // // console.log(result); 201 | // } catch (error) { 202 | // console.error(error); 203 | // } finally { 204 | // await dbClient.close(); 205 | // } 206 | // } 207 | 208 | // main(); 209 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 52 | 53 | IKEA Click & Collect Availability 54 | 55 | 56 | 57 | 62 |
63 |

IKEA Click & CollectAvailability

64 |

updated every minute.

65 |
66 |
67 |
68 |
69 |
70 | Open for new order
71 | Closed for new order
72 | Unknown 73 |
74 |
75 |
76 |
United States
77 |
78 |
79 |

More locations in the United States will appear as soon as Click & Collect service becomes operative. Official updates.

80 |
81 |
82 |
83 |
Canada
84 |
85 |
86 |

More locations in Canada will appear as soon as Click & Collect service becomes operative. Official updates.

87 |
88 | 115 |
116 |
117 |
118 |
119 |
120 |
121 |
The Story
122 |

May 27, 2020

123 |

124 | After a week of trying to order a watering can for my patio herbs from IKEA, by the time I was going to give up, an idea crossed my mind—what about writing some code to get my order through? It was intimidating because I haven't been coding for years, besides the fact that I was just a self-taught developer in the first place. Luckily, my wife supported me, and I decided to do it. 125 |

126 |

127 | On the first day of coding, I spotted a slot and got my order placed, but I didn't want to stop. This is fun! And I want more! Why not make a website to help others like me? Surprisingly, the more I write, the easier it gets, as if the passion for creating beautiful apps has never left me. 128 |

129 |

130 | Three days ago, the first public version of the website went online. I posted a link on reddit in the hope of attracting ideally dozens of users. However, things went well beyond what I expected. So far, ten thousand people have visited my website, doubling every day. People from Canada asked for support. Developers offered free server and code contribution. And most importantly, people are finally getting the desk for their daughter, patio set for their birthday and crib for their baby. I feel so honored to be part of this. 131 |

132 |

133 | I'd like to continue my website free of charge without ads, develop text notification and other features in the future. Please support me! For you guys who already bought me coffees, thank you! 134 |

135 |
136 | Buy me a coffeeBuy me a coffee 137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |

This is not an IKEA website. IKEA® is a registered trademark of Inter-IKEA Systems B.V. in the U.S. and other countries.

146 |
147 | 148 | 149 | 150 | 151 | 152 | 153 | 219 | 220 | 221 | --------------------------------------------------------------------------------