├── .gitignore ├── views ├── index.jade ├── error.jade └── layout.jade ├── config.js ├── public ├── stylesheets │ └── style.css └── javascripts │ ├── app.js │ └── app │ ├── app.vue │ └── views │ ├── integrations.vue │ └── index.vue ├── routes ├── index.js ├── users.js └── api.js ├── db └── index.js ├── readme.md ├── webpack.base.conf.js ├── package.json ├── app.js ├── models ├── integration.js └── app.js ├── utils └── appstore.js └── bin └── www /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | _* 3 | build 4 | *.log -------------------------------------------------------------------------------- /views/index.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | #app 5 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | db: { 3 | name: require('./package.json').name 4 | } 5 | } -------------------------------------------------------------------------------- /views/error.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= message 5 | h2= error.status 6 | pre #{error.stack} 7 | -------------------------------------------------------------------------------- /views/layout.jade: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title= title 5 | body 6 | block content 7 | script(src="/build/app.dist.js") 8 | -------------------------------------------------------------------------------- /public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; 4 | } 5 | 6 | a { 7 | color: #00B7FF; 8 | } 9 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var router = express.Router(); 3 | 4 | /* GET home page. */ 5 | router.get('/', function(req, res, next) { 6 | res.render('index', { title: 'Catchem' }); 7 | }); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /routes/users.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var router = express.Router(); 3 | 4 | /* GET users listing. */ 5 | router.get('/', function(req, res, next) { 6 | res.send('respond with a resource'); 7 | }); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /db/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Sequelize = require('sequelize') 4 | const config = require('../config') 5 | const path = require('path') 6 | 7 | const db = new Sequelize(config.db.name, 'catchem', 'catchem', { 8 | dialect: 'sqlite', 9 | storage: path.resolve(__dirname, '../', '_db/catchem.sqlite') 10 | }) 11 | 12 | module.exports = db -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Catchem (WIP) 2 | 3 | Catchem helps you detect the APPs' price. When the price changes, it will trigger your custom integration such as WebHook. 4 | 5 | # Screenshots 6 | 7 | ![](http://blogscdn.qiniudn.com/catchem1.png) 8 | 9 | ![](http://blogscdn.qiniudn.com/catchem2.png) 10 | 11 | # Build 12 | 13 | ```bash 14 | $ npm install 15 | 16 | $ npm run build 17 | 18 | $ npm run start 19 | ``` 20 | 21 | # License 22 | 23 | MIT License -------------------------------------------------------------------------------- /public/javascripts/app.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter from 'vue-router' 3 | 4 | Vue.use(VueRouter) 5 | 6 | Vue.config.debug = true 7 | 8 | import app from './app/app.vue' 9 | 10 | const router = new VueRouter() 11 | 12 | router.map({ 13 | '/': { 14 | name: 'apps', 15 | component: require('./app/views/index.vue') 16 | }, 17 | 18 | '/integrations': { 19 | name: 'integrations', 20 | component: require('./app/views/integrations.vue') 21 | } 22 | }) 23 | 24 | router.start(app, '#app') 25 | -------------------------------------------------------------------------------- /webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: { 3 | app: './public/javascripts/app.js' 4 | }, 5 | output: { 6 | filename: '[name].dist.js', 7 | path: './public/build/' 8 | }, 9 | module: { 10 | loaders: [ 11 | { 12 | test: /\.vue$/, 13 | loader: 'vue' 14 | }, 15 | { 16 | test: /\.js$/, 17 | loader: 'babel', 18 | query: { 19 | presets: 'es2015' 20 | }, 21 | exclude: /node_modules/ 22 | } 23 | ] 24 | }, 25 | vue: { 26 | postcss: [ 27 | require('postcss-import'), 28 | require('postcss-simple-vars'), 29 | require('postcss-nested') 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /routes/api.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var express = require('express') 4 | var router = express.Router() 5 | 6 | const appstore = require('../utils/appstore') 7 | const appModel = require('../models/app') 8 | const integrationModel = require('../models/integration') 9 | 10 | router 11 | .get('/app', (req, res) => { 12 | appModel.list() 13 | .then(apps => res.send(apps)) 14 | .catch(err => res.send(err)) 15 | }) 16 | 17 | .post('/app', (req, res) => { 18 | const URL = req.body.url 19 | appstore.fetchAppInfo(URL) 20 | .then(info => appModel.add(info)) 21 | .then(app => res.send(app)) 22 | .catch(err => res.send(err)) 23 | }) 24 | 25 | .delete('/app/:id', (req, res) => { 26 | 27 | }) 28 | 29 | .get('/integration', (req, res) => { 30 | integrationModel.list() 31 | .then(integrations => res.send(integrations)) 32 | .catch(err => res.send(err)) 33 | }) 34 | .put('/integration/:id', (req, res) => { 35 | integrationModel.edit(req.params.id, req.body) 36 | .then(() => res.success()) 37 | .catch(err => res.send(err)) 38 | }) 39 | 40 | module.exports = router 41 | -------------------------------------------------------------------------------- /public/javascripts/app/app.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 14 | 15 | 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "catchem-personal", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node ./bin/www", 7 | "dev:static": "webpack --config webpack.base.conf.js -w", 8 | "build": "mkdir _db && ./node_modules/webpack/bin/webpack.js --config webpack.base.conf.js -p" 9 | }, 10 | "dependencies": { 11 | "body-parser": "~1.13.2", 12 | "cheerio": "^0.20.0", 13 | "cookie-parser": "~1.3.5", 14 | "debug": "~2.2.0", 15 | "express": "~4.13.1", 16 | "jade": "~1.11.0", 17 | "morgan": "~1.6.1", 18 | "node-schedule": "^1.0.0", 19 | "sequelize": "^3.19.2", 20 | "serve-favicon": "~2.3.0", 21 | "sqlite3": "^3.1.1", 22 | "superagent": "^1.7.2", 23 | "webpack": "^1.12.13" 24 | }, 25 | "devDependencies": { 26 | "babel-core": "^6.5.2", 27 | "babel-loader": "^6.2.2", 28 | "babel-plugin-transform-runtime": "^6.5.2", 29 | "babel-preset-es2015": "^6.3.13", 30 | "babel-runtime": "^5.8.35", 31 | "css-loader": "^0.23.1", 32 | "jade-loader": "^0.8.0", 33 | "normalize.css": "^3.0.3", 34 | "notie": "^2.1.0", 35 | "nprogress": "^0.2.0", 36 | "postcss-import": "^8.0.2", 37 | "postcss-nested": "^1.0.0", 38 | "postcss-simple-vars": "^1.2.0", 39 | "purecss": "^0.6.0", 40 | "template-html-loader": "0.0.3", 41 | "vue": "^1.0.16", 42 | "vue-hot-reload-api": "^1.3.2", 43 | "vue-html-loader": "^1.1.0", 44 | "vue-loader": "^8.1.1", 45 | "vue-router": "^0.7.11", 46 | "vue-style-loader": "^1.0.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | var path = require('path') 3 | var favicon = require('serve-favicon') 4 | var logger = require('morgan') 5 | var cookieParser = require('cookie-parser') 6 | var bodyParser = require('body-parser') 7 | 8 | var routes = require('./routes/index') 9 | var api = require('./routes/api') 10 | 11 | var app = express() 12 | 13 | // view engine setup 14 | app.set('views', path.join(__dirname, 'views')) 15 | app.set('view engine', 'jade') 16 | 17 | // uncomment after placing your favicon in /public 18 | //app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))) 19 | app.use(logger('dev')) 20 | app.use(bodyParser.json()) 21 | app.use(bodyParser.urlencoded({ extended: false })) 22 | app.use(cookieParser()) 23 | app.use(express.static(path.join(__dirname, 'public'))) 24 | 25 | app.use('/', routes) 26 | app.use('/api', api) 27 | 28 | // catch 404 and forward to error handler 29 | app.use(function(req, res, next) { 30 | var err = new Error('Not Found') 31 | err.status = 404 32 | next(err) 33 | }) 34 | 35 | // error handlers 36 | 37 | // development error handler 38 | // will print stacktrace 39 | if (app.get('env') === 'development') { 40 | app.use(function(err, req, res, next) { 41 | res.status(err.status || 500) 42 | res.render('error', { 43 | message: err.message, 44 | error: err 45 | }) 46 | }) 47 | } 48 | 49 | // production error handler 50 | // no stacktraces leaked to user 51 | app.use(function(err, req, res, next) { 52 | res.status(err.status || 500) 53 | res.render('error', { 54 | message: err.message, 55 | error: {} 56 | }) 57 | }) 58 | 59 | 60 | module.exports = app 61 | -------------------------------------------------------------------------------- /models/integration.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const db = require('../db') 4 | const Sequelize = require('sequelize') 5 | 6 | const Integration = db.define('integration', { 7 | id: { 8 | type: Sequelize.UUID, 9 | defaultValue: Sequelize.UUIDV4, 10 | primaryKey: true 11 | }, 12 | name: Sequelize.STRING, 13 | description: Sequelize.TEXT, 14 | params: Sequelize.STRING, 15 | 16 | // webhook 17 | url: Sequelize.STRING 18 | }) 19 | 20 | Integration.sync().then(() => { 21 | Integration.findOrCreate({ 22 | where: { 23 | name: 'WebHook' 24 | }, 25 | defaults: { 26 | url: '', 27 | params: 'url', 28 | description: 'When a price is applied, Catchem will trigger this WebHook, request the specific URL with data.' 29 | } 30 | }) 31 | 32 | }) 33 | 34 | function edit(id, params){ 35 | return new Promise((resolve, reject) => { 36 | Integration.update(params, {where: { id }}) 37 | .then(() => resolve()) 38 | .catch(err => reject(err)) 39 | }) 40 | } 41 | 42 | function list(){ 43 | return new Promise((resolve, reject) => { 44 | Integration.findAll() 45 | .then(integrations => resolve(integrations)) 46 | .catch(err => reject(err)) 47 | }) 48 | } 49 | 50 | const webhook = { 51 | getUrl(){ 52 | return new Promise((resolve, reject) => { 53 | Integration.findOne({ 54 | where: { 55 | name: 'WebHook' 56 | }, 57 | attributes: ['url'] 58 | }) 59 | .then(webhook => resolve(webhook.url)) 60 | .catch(err => reject(err)) 61 | }) 62 | 63 | } 64 | } 65 | 66 | module.exports = { 67 | list, edit, 68 | webhook 69 | } -------------------------------------------------------------------------------- /utils/appstore.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const cheerio = require('cheerio') 4 | const request = require('superagent') 5 | 6 | // const URL = 'https://itunes.apple.com/cn/app/shadowmatic/id775888026?mt=8' 7 | 8 | function fetchAppInfo(url){ 9 | return new Promise((resolve, reject) => { 10 | request 11 | .get(url) 12 | .end((err, res) => { 13 | if (err) { 14 | reject(err) 15 | } else { 16 | const $ = cheerio.load(res.text) 17 | const itemProps = $('*[itemprop]') 18 | 19 | let info = { 20 | url, 21 | name: [], 22 | screenshot: [] 23 | } 24 | 25 | itemProps.each((index, item) => { 26 | const type = $(item).prop('itemprop') 27 | const src = $(item).prop('src') || $(item).prop('content') 28 | const html = $(item).html() 29 | const content = $(item).text() 30 | 31 | switch(type){ 32 | case 'name': 33 | info[type].push(content) 34 | break 35 | case 'image': 36 | info[type] = src 37 | break 38 | case 'description': 39 | info['textDescription'] = content 40 | info[type] = html 41 | break 42 | case 'screenshot': 43 | info[type].push(src) 44 | break 45 | default: 46 | info[type] = content 47 | break 48 | } 49 | resolve(info) 50 | }) 51 | } 52 | }) 53 | }) 54 | } 55 | 56 | exports.fetchAppInfo = fetchAppInfo 57 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('../app'); 8 | var debug = require('debug')('catchem-personal:server'); 9 | var http = require('http'); 10 | 11 | /** 12 | * Get port from environment and store in Express. 13 | */ 14 | 15 | var port = normalizePort(process.env.PORT || '3000'); 16 | app.set('port', port); 17 | 18 | /** 19 | * Create HTTP server. 20 | */ 21 | 22 | var server = http.createServer(app); 23 | 24 | /** 25 | * Listen on provided port, on all network interfaces. 26 | */ 27 | 28 | server.listen(port); 29 | server.on('error', onError); 30 | server.on('listening', onListening); 31 | 32 | /** 33 | * Normalize a port into a number, string, or false. 34 | */ 35 | 36 | function normalizePort(val) { 37 | var port = parseInt(val, 10); 38 | 39 | if (isNaN(port)) { 40 | // named pipe 41 | return val; 42 | } 43 | 44 | if (port >= 0) { 45 | // port number 46 | return port; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | /** 53 | * Event listener for HTTP server "error" event. 54 | */ 55 | 56 | function onError(error) { 57 | if (error.syscall !== 'listen') { 58 | throw error; 59 | } 60 | 61 | var bind = typeof port === 'string' 62 | ? 'Pipe ' + port 63 | : 'Port ' + port; 64 | 65 | // handle specific listen errors with friendly messages 66 | switch (error.code) { 67 | case 'EACCES': 68 | console.error(bind + ' requires elevated privileges'); 69 | process.exit(1); 70 | break; 71 | case 'EADDRINUSE': 72 | console.error(bind + ' is already in use'); 73 | process.exit(1); 74 | break; 75 | default: 76 | throw error; 77 | } 78 | } 79 | 80 | /** 81 | * Event listener for HTTP server "listening" event. 82 | */ 83 | 84 | function onListening() { 85 | var addr = server.address(); 86 | var bind = typeof addr === 'string' 87 | ? 'pipe ' + addr 88 | : 'port ' + addr.port; 89 | debug('Listening on ' + bind); 90 | } 91 | -------------------------------------------------------------------------------- /public/javascripts/app/views/integrations.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 67 | 68 | -------------------------------------------------------------------------------- /public/javascripts/app/views/index.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 74 | 75 | 123 | -------------------------------------------------------------------------------- /models/app.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const request = require('superagent') 4 | const schedule = require('node-schedule') 5 | 6 | const db = require('../db') 7 | const Sequelize = require('sequelize') 8 | 9 | const appstore = require('../utils/appstore') 10 | 11 | const integrationModel = require('./integration') 12 | 13 | const App = db.define('app', { 14 | id: { 15 | type: Sequelize.UUID, 16 | defaultValue: Sequelize.UUIDV4, 17 | primaryKey: true 18 | }, 19 | status: { 20 | type: Sequelize.INTEGER, 21 | defaultValue: 0 22 | }, 23 | url: Sequelize.STRING, 24 | name: Sequelize.STRING, 25 | icon: Sequelize.STRING, 26 | price: Sequelize.STRING 27 | }) 28 | 29 | App.sync() 30 | 31 | function add(info){ 32 | return new Promise((resolve, reject) => { 33 | App.findOne({ 34 | where: { 35 | name: info.name[0] 36 | } 37 | }) 38 | .then(app => { 39 | if (app) { 40 | // exists 41 | reject({ 42 | status: 2, 43 | message: 'App exists' 44 | }) 45 | } else { 46 | App.create({ 47 | name: info.name[0], 48 | price: info.price, 49 | icon: info.image, 50 | url: info.url 51 | }) 52 | .then(app => resolve(app)) 53 | .catch(err => reject(err)) 54 | } 55 | }).catch(err => reject(err)) 56 | }) 57 | } 58 | 59 | function list(){ 60 | return new Promise((resolve, reject) => { 61 | App.findAll() 62 | .then(apps => resolve(apps)) 63 | .catch(err => reject(err)) 64 | }) 65 | } 66 | 67 | function edit(id, info){ 68 | return new Promise((resolve, reject) => { 69 | App.update(info, { 70 | where: { 71 | id: id 72 | } 73 | }) 74 | .then(app => resolve(app)) 75 | .catch(err => reject(err)) 76 | }) 77 | } 78 | 79 | function check(){ 80 | console.log('======= Checking begin =======') 81 | 82 | return new Promise((resolve, reject) => { 83 | let count = 0 84 | list().then(apps => apps.map((app, index) => { 85 | appstore.fetchAppInfo(app.url) 86 | .then(info => { 87 | console.log('Fetched', info.name) 88 | count++ 89 | console.log(count, apps.length) 90 | if (count === apps.length) { 91 | resolve() 92 | } 93 | if (info.price !== app.price) { 94 | console.log(info.name, '\'s price had changed') 95 | 96 | // figure out up or down or free 97 | let numNewPrice = info.price.substr(1) 98 | let numOriginPrice = app.price.substr(1) 99 | 100 | let status = 0 // 0.平稳 1.降价 2.限免 101 | 102 | if (isNaN(numNewPrice)) { 103 | // free 104 | status = 2 105 | } else { 106 | status = numNewPrice > numOriginPrice ? 0 : 1 107 | } 108 | 109 | // trigger service interface 110 | integrationModel.webhook.getUrl() 111 | .then(url => { 112 | if (url) { 113 | request 114 | .post(url) 115 | .send({ 116 | status, 117 | name: info.name[0], 118 | price: info.price, 119 | description: info.textDescription 120 | }) 121 | .end((err) => { 122 | if (err) { console.error('Triggering WebHook failed.', err) } 123 | }) 124 | } 125 | }) 126 | 127 | // modify app info 128 | edit(app.id, { 129 | status, 130 | price: info.price 131 | }) 132 | .catch(err => reject(err)) 133 | 134 | } 135 | }) 136 | })) 137 | }) 138 | } 139 | 140 | // check every 2 hours 141 | function cronJob(){ 142 | check().then(() => console.log('Checking finsihed')) 143 | } 144 | 145 | cronJob() 146 | 147 | let job = schedule.scheduleJob('0 */2 * * *', () => cronJob()) 148 | 149 | 150 | module.exports = { 151 | add, list, check, edit 152 | } 153 | --------------------------------------------------------------------------------