├── .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 | 
8 |
9 | 
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 |
2 | #logo Catchem
3 | #slogan Buy APPs at the right time
4 | header
5 | nav
6 | a(v-link="{ name: 'apps', exact: true }") APPS
7 | a(v-link="{ name: 'integrations', exact: true }") INTEGRATIONS
8 | router-view
9 |
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 |
2 | #integrations
3 | .integration(v-for="integration in integrations")
4 | h3 {{ integration.name }}
5 | input(v-for="param in integration.params.split(',')", v-model="params[integration.name][param]", type="text", placeholder="{{ param }}", value="{{ integration[param] }}")
6 | p.description {{ integration.description }}
7 | button(@click="modify(integration.id, integration.name)") Save
8 |
9 |
10 |
11 |
67 |
68 |
--------------------------------------------------------------------------------
/public/javascripts/app/views/index.vue:
--------------------------------------------------------------------------------
1 |
2 | #apps
3 | #add
4 | input(v-model="appUrl", @keyup.enter="add" ,type="text", placeholder="App URL")
5 | a(href="javascript:void(0)", @click="add") ADD
6 | p(v-show="apps.length === 0") loading...
7 | .pure-g(v-else)
8 | .pure-u-1-6(v-for="app in apps")
9 | .app
10 | .icon
11 | img.pure-img(:src="app.icon")
12 | .name {{ app.name }}
13 | .price {{ app.price }}
14 |
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 |
--------------------------------------------------------------------------------