├── .dockerignore ├── .foreverignore ├── .gitignore ├── .gitmodules ├── .npmrc ├── CHECKS ├── Gruntfile.js ├── Makefile ├── README.md ├── app.js ├── apps.json ├── bower.json ├── cluster.js ├── conf ├── default.conf.js ├── development.conf.tmpl.js ├── index.js ├── jitsu.conf.js └── test.conf.js ├── database └── index.js ├── lib ├── README.md ├── assets.js ├── cached.js ├── central.js ├── douban.js ├── ip2geo.js ├── mongo │ ├── index.js │ ├── model.js │ └── pool.js ├── passport.js ├── raven.js ├── redis │ └── index.js ├── task.js ├── template │ ├── consts.js │ └── helpers.js └── utils │ ├── index.js │ ├── strftime.js │ └── text.js ├── models ├── consts.js ├── interest │ ├── base.js │ ├── book.js │ └── index.js ├── mixins │ └── data.js ├── subject │ ├── base.js │ ├── book.js │ └── index.js ├── toplist │ ├── book.js │ └── index.js └── user │ ├── click.js │ ├── friends.js │ ├── index.js │ ├── interest.js │ ├── progress.js │ └── stats.js ├── package-lock.json ├── package.json ├── serve ├── admin │ └── index.js ├── api │ └── index.js ├── auth │ ├── index.js │ └── utils.js ├── index.js ├── mine │ └── index.js ├── misc.js ├── monitor │ └── index.js ├── oauth │ └── index.js ├── people │ ├── click.js │ └── index.js ├── queue.js ├── tag │ └── index.js ├── top │ └── index.js └── utils │ ├── errorHandler.js │ └── index.js ├── static ├── css │ ├── base.styl │ ├── base │ │ ├── feel.styl │ │ ├── layout.styl │ │ └── reset.styl │ ├── bootstrap.css │ ├── chart │ │ ├── basic.styl │ │ ├── book.styl │ │ └── details.styl │ ├── home.styl │ ├── interests │ │ └── list.styl │ ├── jiathis_share.css │ ├── mine.styl │ ├── mine │ │ └── click.styl │ ├── tag.styl │ ├── toplist │ │ └── basic.styl │ └── widgets │ │ ├── avatar_list.styl │ │ ├── rbox.styl │ │ └── ticker.styl ├── dist │ ├── bdsitemap.txt │ ├── favicon.ico │ ├── fonts │ ├── jiathis_utility.html │ └── pics ├── favicon.ico ├── fonts │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.svg │ ├── glyphicons-halflings-regular.ttf │ └── glyphicons-halflings-regular.woff ├── js │ ├── chart │ │ ├── all.js │ │ ├── bar.js │ │ ├── consts.js │ │ ├── pie.js │ │ └── treemap.js │ ├── d3.js │ ├── do.cmd.js │ ├── do.core.js │ ├── do.js │ ├── homepage │ │ └── ticker.js │ ├── jiathis.js │ ├── lodash.js │ ├── main.js │ ├── mine │ │ ├── booter.js │ │ └── click.js │ ├── people │ │ ├── abbrs.js │ │ ├── bars.js │ │ ├── booter.js │ │ ├── crossfilter.js │ │ └── progress.js │ └── utils │ │ └── datetime.js └── pics │ ├── alipay-qrcode.png │ ├── blank.gif │ ├── favicon.ico │ ├── login_with_douban_32.png │ ├── wechat-qrcode.jpg │ └── wechat-qrcode.png ├── tasks ├── README.md ├── click │ ├── book.js │ └── index.js ├── compute │ ├── book.js │ ├── common.js │ └── index.js ├── index.js ├── interest │ ├── index.js │ └── stream.js ├── quotes │ └── index.js ├── toplist │ ├── _obsolete.js │ ├── by_tag.js │ └── index.js └── utils │ ├── index.js │ └── parser.js ├── templates ├── 403.jade ├── 404.jade ├── 500.jade ├── auth │ ├── login.jade │ └── mods │ │ ├── douban.jade │ │ └── login_form.jade ├── common │ ├── footer.jade │ ├── navbar.jade │ └── track.jade ├── index.jade ├── layout │ ├── basic.jade │ └── message.jade ├── mine │ ├── index.jade │ └── mods │ │ ├── jump.jade │ │ ├── mixins.jade │ │ ├── sidebar.jade │ │ └── tmpl_friends_list.html ├── misc │ ├── about.jade │ ├── about_click.jade │ ├── about_privacy.jade │ └── donate.jade ├── monitor │ └── index.jade ├── people │ ├── cases │ │ ├── failed.jade │ │ ├── never.jade │ │ ├── sync_failed.jade │ │ ├── wait.jade │ │ └── zero.jade │ ├── click │ │ ├── index.jade │ │ ├── loading.jade │ │ ├── mods │ │ │ ├── done.jade │ │ │ ├── mixins.jade │ │ │ ├── quote.jade │ │ │ └── sidebar.jade │ │ ├── no_other.jade │ │ ├── not_ready.jade │ │ └── quote.jade │ ├── failed.jade │ ├── index.jade │ ├── interests.jade │ ├── mixins │ │ ├── enable_links.jade │ │ ├── interest.jade │ │ └── paginator.jade │ ├── mods │ │ ├── click.jade │ │ ├── intro.jade │ │ ├── progress.jade │ │ ├── restats.jade │ │ ├── resync.jade │ │ ├── share.html │ │ ├── stats.jade │ │ ├── stats_js.jade │ │ ├── sync.jade │ │ └── tmpl_click.html │ ├── quote.jade │ ├── stats │ │ ├── book.jade │ │ ├── book │ │ │ ├── done.jade │ │ │ ├── favs.jade │ │ │ ├── history.jade │ │ │ ├── hots.jade │ │ │ ├── keywords.jade │ │ │ ├── mixins.jade │ │ │ ├── overview.jade │ │ │ ├── quote.jade │ │ │ ├── tags.jade │ │ │ └── tops.jade │ │ └── book_sub.jade │ └── sub.jade ├── tag │ ├── index.jade │ └── mods │ │ ├── books.jade │ │ └── users.jade ├── toplist │ └── index.jade └── widgets │ ├── latest_synced.jade │ └── tmpl_latest_synced.html └── tools ├── clean.js ├── run.sh ├── toplist.js └── update.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .node_modules 3 | -------------------------------------------------------------------------------- /.foreverignore: -------------------------------------------------------------------------------- 1 | node_modules/**/*.js 2 | **/.git/** 3 | *.bak 4 | .DS_Store 5 | Makefile 6 | Gruntfile.js 7 | *.styl 8 | *.css 9 | *.gif 10 | *.png 11 | *.jpg 12 | *.min.js 13 | *.jade 14 | *.yaml 15 | *.xml 16 | *.md 17 | **.statictmp/** 18 | **/static/**/* 19 | templates/** 20 | var/* 21 | *.rdb 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm* 3 | cache 4 | stable_cache 5 | *.css 6 | *.bak 7 | *.swp 8 | .DS_Store 9 | var/*.log 10 | conf/development.conf.js 11 | conf/production.conf.js 12 | tmp/ 13 | bower_components/ 14 | static/hash.json 15 | static/source_hash.json 16 | static/build 17 | static/dist 18 | static/*.html 19 | static/*.txt 20 | static/.file_etags 21 | *.min.* 22 | *.rdb 23 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "static/components/bootstrap"] 2 | path = static/components/bootstrap 3 | url = git://github.com/twitter/bootstrap.git 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | unsafe-perm = true 2 | -------------------------------------------------------------------------------- /CHECKS: -------------------------------------------------------------------------------- 1 | WAIT=10 2 | TIMEOUT=10 3 | ATTEMPTS=3 4 | 5 | / 豆瓣酱 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | start: 2 | #@export DEBUG="dbj:*" && forever -w app.js 3 | @export DEBUG="dbj* cache*" && supervisor -w 'lib,serve,models,app.js,conf,tasks,Gruntfile.js' -p 1000 app.js 4 | 5 | debug: 6 | @export DEBUG="*" && supervisor -w 'lib,serve,models,app.js,conf,tasks,Gruntfile.js' --debug -p 1000 app.js 7 | 8 | grunt: 9 | @export DEBUG="dbj*" && grunt 10 | 11 | build: 12 | @export NODE_ENV="production" && export DEBUG="dbj:*" && \ 13 | npm install --production && \ 14 | bower install && \ 15 | grunt build --force 16 | 17 | watch: 18 | @export DEBUG="dbj:*" && grunt watch 19 | 20 | cleanhash: 21 | @grunt clean:hash 22 | 23 | init: 24 | @cp -iv ./conf/development.conf.tmpl.js ./conf/development.conf.js 25 | 26 | update: 27 | @export NODE_ENV=production && export DEBUG="dbj:* -*:verbose" && ./tools/update.js 28 | 29 | toplist: 30 | @export DEBUG="dbj:*" && ./tools/toplist.js 31 | 32 | init_bootstrap: 33 | git submodule init 34 | git submodule update 35 | cd ./static/components/bootstrap/ && npm install && make 36 | 37 | tail: 38 | @tail -f -n 60 /srv/log/nodejs/doubanj.log 39 | 40 | .PHONY: dev watch build deploy 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [豆瓣酱] [![repo dependency](https://david-dm.org/ktmud/doubanj.png)](https://david-dm.org/ktmud/doubanj) 2 | 3 | ## This repo has been deprecated. 由于豆瓣关闭了 API 通道,本网站已下线。 4 | 5 | [豆瓣](http://www.douban.com) 私人收藏数据可视化。 6 | 7 | ## 依赖 8 | 9 | ### 数据库服务器 10 | 11 | 同时依赖 mongodb 和 redis ,配置参数参见 `conf/default.conf.js` 。 12 | 13 | ### 工具包 14 | 15 | npm install forever -g 16 | npm install component -g 17 | npm install grunt-cli -g 18 | npm install 19 | 20 | ## 开始开发 21 | 22 | make init 23 | make grunt 24 | make 25 | 26 | make 的默认命令是使用 `forever` 执行 `app.js` 。 27 | 28 | 如果需要修改静态文件,请执行 `make watch` ,利用 `grunt` 监视静态文件改动。 29 | 30 | ## 一点说明 31 | 32 | ### MongoDB 的用处 33 | 34 | 1. 存储用户账户信息、收藏信息、条目信息 35 | 2. 利用 [aggregation](http://docs.mongodb.org/manual/applications/aggregation/) 生成统计结果 36 | 37 | ### redis 的用处 38 | 39 | 1. 替代 memcached 的缓存服务 40 | 2. 存储统计结果(计划中) 41 | 42 | ### 队列管理 43 | 44 | 使用 [node-pool](https://github.com/coopernurse/node-pool),数据库请求、API请求、统计请求,都有分别的队列。 45 | 46 | ### 静态文件 47 | 48 | - 依赖的开源库都用 component 来管理。 49 | - 使用 grunt 来打包。具体配置参见 `Gruntfile.js` 。 50 | - 服务器递送的总是 `/static/dist` 目录下的文件,调试时也要保证 dist 目录下有所有需要的文件。没有 fallback 。因此请保证修改静态文件时,watch 有运行。 51 | 52 | #### 客户端JS的模块化 53 | 54 | - static/js/do.core.js 是由豆瓣的 do.js 修改而来的文件加载器 55 | - 用了 component-build 的一套东西,参看 static/js/do.cmd.js 56 | - Gruntfile.js 里定义了对 js 文件包裹 CommonJS `require` 定义的命令 57 | - 使用模版配套的 `#{urlmap()}` 方法为 Do 生成所需文件的真实地址 58 | - 使用 `Do('module1', 'module2', ...` 显式延时加载你需要的模块,模块名即文件名,在 Do 内部安全地使用 `require('xxx')` 59 | 具体使用实例参见 static/js/people/booter.js 60 | 61 | #### 版本管理 62 | 63 | 发布上线前执行 `grunt build` ,将为压缩后的文件生成一个 hashmap (即 static/hash.json ),并重命名文件为 static/dist/js/xx\_HASH.js 格式。 64 | 为了保证这套机制的顺利运行,请保证新加的静态文件名中不包括下划线(\_)。 65 | 66 | #### 提供 API Key 67 | 68 | 采集豆瓣数据需要使用豆瓣 API。请配置一下环境变量来制定豆瓣 API KEY: 69 | 70 | - DOUBAN_APP_KEY 71 | - DOUBAN_APP_SECRET 72 | - DOUBAN_APP_MORE - 格式:key1:secret1, key2:secret2 73 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | //if (!module.parent) process.on('uncaughtException', function(err, next) { 2 | //var msg; 3 | //if (err instanceof Error) { 4 | //msg = '[err]: ' + err + '\n' + err.stack; 5 | //} else { 6 | //msg = (err.name || err.reason || err.message); 7 | //console.error(err); 8 | //} 9 | //console.error(msg); 10 | //next && next(); 11 | //}); 12 | 13 | var URL = require('url'); 14 | var express = require('express'); 15 | 16 | var central = require('./lib/central'); 17 | var passport = require('./lib/passport'); 18 | var ip2geo = require('./lib/ip2geo'); 19 | var serve = require('./serve'); 20 | var jade = require('jade'); 21 | var Redis = require('redis'); 22 | var session = require('express-session'); 23 | var RedisStore = require('connect-redis')(session); 24 | var methodOverride = require('method-override'); 25 | var serveStatic = require('serve-static'); 26 | var cookieParser = require('cookie-parser'); 27 | var bodyParser = require('body-parser'); 28 | var csrf = require('csurf'); 29 | 30 | var TWO_WEEKS = 60 * 60 * 24 * 14; 31 | 32 | // initial bootstraping, only serve the API 33 | module.exports.boot = function() { 34 | var app = express(); 35 | var conf = central.conf; 36 | app.enable('trust proxy') 37 | 38 | app.engine('jade', jade.renderFile); 39 | 40 | app.set('view engine', 'jade'); 41 | app.set('view cache', !central.conf.debug); 42 | app.set('views', __dirname + '/templates'); 43 | 44 | app.use(serveStatic(central.assets.root, { maxAge: TWO_WEEKS })); 45 | 46 | app.use(methodOverride()); 47 | app.use(cookieParser()); 48 | 49 | // parse application/x-www-form-urlencoded 50 | app.use(bodyParser.urlencoded({ extended: false })) 51 | // parse application/json 52 | app.use(bodyParser.json()) 53 | 54 | app.use(session({ 55 | secret: conf.salt, 56 | store: new RedisStore({ 57 | client: Redis.createClient(conf.redis) 58 | }) 59 | })); 60 | app.use(ip2geo.express()); 61 | app.use(passport.initialize()); 62 | app.use(passport.session()); 63 | 64 | app.locals = { 65 | ...app.locals, 66 | ...central.template_helpers 67 | } 68 | 69 | app.use(csrf()); 70 | 71 | app.use(function(req, res, next) { 72 | req.is_ssl = req.headers['x-forwarded-proto'] === 'https'; 73 | res.locals.req = req; 74 | res.locals._csrf = req.csrfToken(); 75 | res.locals.static = function(url) { 76 | if (req.is_ssl) return URL.resolve(central.conf.https_root, url); 77 | return central.assets.fileUrl(url); 78 | }; 79 | res.locals.strftime = central.template_helpers.strftime.bind(res.locals); 80 | next(); 81 | }); 82 | 83 | serve(app, central); 84 | 85 | app.listen(central.conf.port); 86 | central.log('Now listening on ' + central.conf.port); 87 | central.log('Site root: ' + central.conf.site_root); 88 | }; 89 | 90 | if (!module.parent) { 91 | //setTimeout(function() { 92 | module.exports.boot(); 93 | //}, 20000); 94 | } 95 | -------------------------------------------------------------------------------- /apps.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktmud/doubanj/f7b5cc08fcb878ec655def7df7c7ff20244cdce9/apps.json -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doubanj", 3 | "version": "0.0.0", 4 | "homepage": "https://github.com/ktmud/doubanj", 5 | "authors": [ 6 | "Jesse Yang " 7 | ], 8 | "license": "MIT", 9 | "private": true, 10 | "ignore": [ 11 | "**/.*", 12 | "node_modules", 13 | "bower_components", 14 | "test", 15 | "tests" 16 | ], 17 | "dependencies": { 18 | "bootstrap": "~3.3.4", 19 | "d3": "~3.1.3", 20 | "lodash": "~2.4.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cluster.js: -------------------------------------------------------------------------------- 1 | if (!module.parent) process.on('uncaughtException', function(err, next) { 2 | var msg; 3 | if (err instanceof Error) { 4 | msg = '[err]: ' + err + '\n' + err.stack; 5 | } else { 6 | msg = (err.name || err.reason || err.message); 7 | console.error(err); 8 | } 9 | console.error(msg); 10 | next(); 11 | }); 12 | 13 | var cluster = require('cluster'); 14 | var util = require('util'); 15 | var app = require(__dirname + '/app.js'); 16 | var central = require(__dirname + '/lib/central.js'); 17 | 18 | var numCPUs = Math.min(central.conf.worker, require('os').cpus().length); 19 | 20 | function startWorker() { 21 | var worker = cluster.fork(); 22 | } 23 | 24 | if (cluster.isMaster) { 25 | // Fork workers. 26 | for (var i = 0; i < numCPUs; i++) { 27 | startWorker(); 28 | } 29 | cluster.on('death', function(worker) { 30 | console.log('worker ' + worker.pid + ' died. restart...'); 31 | startWorker(); 32 | }); 33 | } else { 34 | app.boot(); 35 | } 36 | -------------------------------------------------------------------------------- /conf/default.conf.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var env = process.env; 3 | 4 | var MONGO_HOST = env.MONGODB_HOST ? (env.MONGODB_HOST + ':' + (env.MONGODB_PORT || 27017)) : null; 5 | var ARR_SPLITTER = /\s*,\s*/g; 6 | var is_prod = env.NODE_ENV == 'production'; 7 | 8 | function getDoubanMore() { 9 | var ret = []; 10 | if (env.DOUBAN_APP_MORE) { 11 | ret = env.DOUBAN_APP_MORE.trim().split(ARR_SPLITTER).map(function(item, i) { 12 | var tmp = item.split(':'); 13 | return { 14 | key: tmp[0], 15 | secret: tmp[1], 16 | limit: env.DOUBAN_APP_MORE_LIMIT || 10 17 | } 18 | }); 19 | } 20 | return ret; 21 | } 22 | 23 | /** 24 | * some default settings 25 | */ 26 | module.exports = { 27 | debug: false, 28 | 29 | site_name: '豆瓣酱', 30 | 31 | // the port of the root server 32 | port: env.PORT || 5000, 33 | ssl_root: env.SSL_ROOT, 34 | site_root: env.SITE_ROOT || (is_prod ? 'http://doubanj.yjc.me' : 'http://localhost:5000'), 35 | assets_root: env.ASSETS_ROOT || (is_prod ? 'http://doubanj.yjc.me' : 'http://localhost:5000'), 36 | 37 | salt: env.COOKIE_SALT || 'keyboardcatndog', 38 | 39 | // the Sentry client auth url 40 | // neccessray for tracking events in Sentry 41 | raven: null, 42 | 43 | mongo: { 44 | url: env.MONGO_URL, 45 | dbname: env.MONGODB_DATABASE || 'doubanj', 46 | username: env.MONGODB_USERNAME || null, 47 | password: env.MONGODB_PASSWORD || null, 48 | servers: [ MONGO_HOST || '127.0.0.1:27017'] 49 | }, 50 | 51 | redis: { 52 | url: env.REDIS_URL || 'redis://127.0.0.1:6379', 53 | port: env.REDIS_PORT || '6379', 54 | host: env.REDIS_IP || '127.0.0.1', 55 | prefix: 'doubanj_', 56 | // only set default ttl when there is a memory limit 57 | ttl: 7 * 24 * 60 * 60, // in seconds 58 | }, 59 | 60 | // 管理员的豆瓣 uid 61 | admin_users: env.ADMIN_USERS ? env.ADMIN_USERS.split(ARR_SPLITTER) : ['yajc'], 62 | 63 | douban: { 64 | limit: env.DOUBAN_APP_LIMIT || 10, // request limit per minute 65 | key: env.DOUBAN_APP_KEY || 'my', 66 | secret: env.DOUBAN_APP_SECRET || 'god' 67 | }, 68 | // more random api keys for public informations 69 | douban_more: getDoubanMore(), 70 | 71 | // google analytics id 72 | ga_id: '' 73 | }; 74 | -------------------------------------------------------------------------------- /conf/development.conf.tmpl.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DEVELOPMENT Environment settings 3 | */ 4 | module.exports = { 5 | site_name: '豆瓣酱 - 开发模式', 6 | debug: true, 7 | douban: { 8 | limit: 40, // request limit per minute 9 | key: '11111', // your douban api key (client_id) 10 | secret: '2222' // your douban api secret 11 | }, 12 | // add more keys for douban api public information 13 | douban_more: [ 14 | { 15 | limit: 40, 16 | key: '1111', 17 | secret: '2222' 18 | } 19 | ], 20 | //debug: false, 21 | }; 22 | -------------------------------------------------------------------------------- /conf/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * red configuration for different environment. 3 | */ 4 | var _ = require('lodash'); 5 | 6 | /** 7 | * export configurations. 8 | */ 9 | module.exports = readConfig(); 10 | 11 | /** 12 | * read config from conf.js 13 | * @return {object} express settings. 14 | */ 15 | function readConfig() { 16 | var NODE_ENV = global.process.env.NODE_ENV || 'development'; 17 | var defaultConf = require('./default.conf.js'); 18 | var conf = {}; 19 | 20 | try { 21 | // load from $NODE_ENV.conf.js file 22 | conf = require('./' + NODE_ENV + '.conf.js'); 23 | } catch (e) {} 24 | 25 | conf = _.merge({}, defaultConf, conf); 26 | conf.ssl_root = conf.ssl_root || conf.site_root; 27 | 28 | removeTailingSlash(conf, 'assets_root'); 29 | removeTailingSlash(conf, 'site_root'); 30 | removeTailingSlash(conf, 'ssl_root'); 31 | 32 | // cryptokey for session 33 | conf.secret = createRandomString(); 34 | 35 | return conf; 36 | } 37 | 38 | function removeTailingSlash(conf, k) { 39 | var str = conf[k]; 40 | if (str && str[str.length - 1] == '/') { 41 | conf[k] = str.slice(0, -1); 42 | } 43 | } 44 | 45 | /** 46 | * Random string for cryptoKey 47 | * @return {string} randomString. 48 | */ 49 | function createRandomString() { 50 | var chars = '0123456789;[ABCDEFGHIJKLMfi]NOPQRSTUVWXTZ#&*abcdefghiklmnopqrstuvwxyz'; 51 | var string_length = 10; 52 | var randomString = ''; 53 | for (var i = 0; i < string_length; i++) { 54 | var rnum = Math.floor(Math.random() * chars.length); 55 | randomString += chars.substring(rnum, rnum + 1); 56 | } 57 | return randomString; 58 | } 59 | -------------------------------------------------------------------------------- /conf/jitsu.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | debug: false 3 | }; 4 | -------------------------------------------------------------------------------- /conf/test.conf.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var fs = require('fs'); 3 | 4 | var cwd = process.cwd(); 5 | 6 | var log = fs.createWriteStream(cwd + '/var/stdout.log'); 7 | //var err = fs.createWriteStream(cwd + '/var/stderr.log'); 8 | 9 | util.log = console.log = console.info = function(t) { 10 | var out; 11 | if (t && ~t.indexOf('%')) { 12 | out = util.format.apply(util, arguments); 13 | process.stdout.write(out + '\n'); 14 | return; 15 | } else { 16 | out = Array.prototype.join.call(arguments, ' '); 17 | } 18 | out && log.write(out + '\n'); 19 | }; 20 | 21 | //console.error = console.warn = function() { 22 | //var out = Array.prototype.join.call(arguments, ' '); 23 | //out && err.write(out + '\n'); 24 | //}; 25 | 26 | var dev = require('./development.conf'); 27 | 28 | module.exports = central.lib._.defaults({ 29 | upyun: null, 30 | debug: false 31 | }, dev); 32 | -------------------------------------------------------------------------------- /database/index.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var debug = require('debug'); 3 | var log = debug('dbj:database'); 4 | 5 | 6 | // unique key index option 7 | var index_options = { 8 | unique: true, 9 | dropDups: true, 10 | w: 1 11 | }; 12 | var sparse_option = { 13 | background: true, 14 | sparse: 1, 15 | }; 16 | 17 | module.exports = function(db, next) { 18 | db.collection('book_interest', function(err, r) { 19 | log('ensuring database "book_interest"...'); 20 | var n = 4; 21 | function _tick(err, r) { 22 | n--; 23 | if (err) console.error(err); 24 | if (n <= 0) tick(); 25 | } 26 | r.ensureIndex({ 'user_id': 1, 'updated': 1, }, sparse_option, _tick); 27 | r.ensureIndex({ 'user_id': 1, 'rating.value': -1, }, sparse_option, _tick); 28 | r.ensureIndex({ 'user_id': 1, 'status': 1, }, sparse_option, _tick); 29 | r.ensureIndex({ 'status': 1, 'commented': 1 }, sparse_option, _tick); 30 | }); 31 | 32 | db.collection('book', function(err, r) { 33 | log('ensuring database "book"...'); 34 | var n = 5; 35 | function _tick(err, r) { 36 | n--; 37 | if (err) console.error(err); 38 | if (n <= 0) tick(); 39 | } 40 | 41 | r.ensureIndex({ 'raters': -1 }, sparse_option, _tick); 42 | r.ensureIndex({ 'rated': -1 }, sparse_option, _tick); 43 | r.ensureIndex({ 'pages': -1 }, sparse_option, _tick); 44 | r.ensureIndex({ 'price': -1 }, sparse_option, _tick); 45 | r.ensureIndex({ 'pubdate': -1 }, sparse_option, _tick); 46 | }); 47 | 48 | db.collection('user', function(err, r) { 49 | log('ensuring database "users"...'); 50 | var n = 5; 51 | function _tick(err, r) { 52 | n--; 53 | if (err) console.error(err); 54 | if (n <= 0) tick(); 55 | } 56 | r.ensureIndex({ 'uid': 1 }, index_options, _tick); 57 | r.ensureIndex({ 'book_synced_n': 1 }, sparse_option, _tick); 58 | r.ensureIndex({ 'last_statsed': 1 }, sparse_option, _tick); 59 | r.ensureIndex({ 'last_synced': 1 }, sparse_option, _tick); 60 | r.ensureIndex({ 'last_synced_status': 1, 'book_stats.n_done': -1 }, sparse_option, _tick); 61 | //r.ensureIndex({ 'mtime': 1 }, _tick); 62 | }); 63 | 64 | var n = 3; 65 | function tick() { 66 | n--; 67 | if (n === 0) next(null, db); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /lib/README.md: -------------------------------------------------------------------------------- 1 | ## central.js 2 | 3 | 会输出一个全局变量 `central` ,主要是为了方便的访问 corelib 启动后的应用程序状态,避免重复写很多 `require` 4 | 5 | ## assets.js 6 | 7 | 静态文件 url 输出辅助 8 | 9 | ## auth.js 10 | 11 | 暂时没有用到 12 | 13 | ## douban.js 14 | 15 | 扩展的 OAuth2 Client ,以方便地请求豆瓣API 16 | 17 | ## raven.js 18 | 19 | 用于向 [Sentry](https://github.com/mattrobenolt/raven-node) 发送错误消息 20 | 21 | ## task.js 22 | 23 | 利用 [node-pool](https://github.com/coopernurse/node-pool) 搭建任务队列池 24 | -------------------------------------------------------------------------------- /lib/assets.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Static Assets files management 3 | */ 4 | var fs = require('fs'); 5 | var path = require('path'); 6 | var crypto = require('crypto'); 7 | var istatic = require('istatic'); 8 | var cwd = process.cwd(); 9 | var conf = require(cwd + '/conf'); 10 | 11 | var root = cwd + '/static/dist'; 12 | 13 | var reg_log = /_log\(.*?\);/g; 14 | istatic.default({ 15 | root: root, 16 | debug: conf.debug, 17 | compress: !conf.debug, 18 | ttl: conf.debug ? 0 : 60 * 60 * 24 * 7, 19 | fresh: conf.debug, 20 | js: { 21 | filter: function(str) { 22 | return str.replace(reg_log, ''); 23 | } 24 | } 25 | }); 26 | 27 | var hash_cache = {}; 28 | try { 29 | hash_cache = require(cwd + '/static/hash.json'); 30 | } catch (e) { 31 | if (e.code == 'MODULE_NOT_FOUND' || e.code == 'ENOENT') { 32 | console.error('No existing static hashmap.'); 33 | } else { 34 | throw e; 35 | } 36 | } 37 | 38 | var reg_css_js = /\.(css|js)$/; 39 | 40 | function fileUrl(p) { 41 | if (p[0] == '/') p = p.slice(1); 42 | if (conf.debug || !reg_css_js.test(p)) return conf.assets_root + '/' + p; 43 | 44 | var hash = hash_cache[p]; 45 | if (hash) { 46 | var ext = path.extname(p); 47 | p = path.join(path.dirname(p), path.basename(p, ext) + '_' + hash + ext); 48 | } 49 | return conf.assets_root + '/' + p; 50 | } 51 | 52 | function urlMap() { 53 | var files = [].slice.call(arguments); 54 | var ret = {}; 55 | files.forEach(function(f) { 56 | if (!path.extname(f)) f = path.join('js', f + '.js'); 57 | ret[conf.assets_root + '/' + f] = fileUrl(f); 58 | }); 59 | return ret; 60 | } 61 | 62 | function readFile(filepath, encoding) { 63 | var contents = ''; 64 | try { 65 | contents = fs.readFileSync(String(filepath), encoding); 66 | } catch (e) {} 67 | return contents; 68 | } 69 | function md5(contents) { 70 | var shasum = crypto.createHash('md5'); 71 | shasum.update(contents); 72 | return shasum.digest('hex').slice(0, 10); 73 | } 74 | 75 | // pass in some static files, get their hash value 76 | function hashMap() { 77 | var files = [].slice.call(arguments); 78 | var ret = {}; 79 | files.forEach(function(f) { 80 | ret[f] = getHash(f.indexOf('.') == -1 ? 'js/' + f + '.js' : f); 81 | }); 82 | return ret; 83 | } 84 | function getHash(f, fresh, r, encoding){ 85 | r = typeof r !== 'string' ? root : r; 86 | return fresh ? (hash_cache[f] = md5(readFile(path.join(r, f), encoding))) : hash_cache[f]; 87 | } 88 | 89 | module.exports = { 90 | root: root, 91 | istatic: istatic, 92 | hashMap: hashMap, 93 | urlMap: urlMap, 94 | getHash: getHash, 95 | fileUrl: fileUrl 96 | }; 97 | -------------------------------------------------------------------------------- /lib/cached.js: -------------------------------------------------------------------------------- 1 | var Redis = require('redis') 2 | var conf = require('../conf') 3 | var Cacheable = require('cacheable') 4 | 5 | var client = Redis.createClient(conf.redis) 6 | 7 | module.exports = new Cacheable({ 8 | prefix: 'doubanj:', 9 | client: client 10 | }) 11 | -------------------------------------------------------------------------------- /lib/central.js: -------------------------------------------------------------------------------- 1 | /** 2 | * To export a global variable `central` 3 | */ 4 | var central = {}; 5 | 6 | var debug = require('debug'); 7 | var log = central.log = debug('dbj:log'); 8 | var verbose = central.log.verbose = debug('dbj:verbose'); 9 | var error = central.log.error = debug('dbj:error'); 10 | var assets = require('./assets'); 11 | var consts = require('../models/consts'); 12 | var utils = require('./utils'); 13 | var conf = central.conf = require('../conf'); 14 | var raven = central.raven = require('./raven'); 15 | var redis = central.redis = require('./redis')(conf.redis); 16 | 17 | central.pwd = central.cwd = process.cwd(); 18 | central.request = require('request'); 19 | central.boot_time = new Date(); 20 | central.DEBUG = central.conf.debug; 21 | 22 | if (!('https_root' in conf)) { 23 | conf.https_root = conf.site_root.replace('http://', 'https://'); 24 | } 25 | 26 | // global consts 27 | utils.extend(central, consts); 28 | 29 | central.utils = utils; 30 | central.assets = assets; 31 | central.task = require('./task'); 32 | central.mongo = require('./mongo'); 33 | 34 | redis.client.on('error', function(err) { 35 | raven.error(err, { 36 | tags: { 37 | cate: 'redis' 38 | }, 39 | }); 40 | }); 41 | 42 | global.central = module.exports = central; 43 | 44 | central.template_helpers = require('./template/helpers.js'); 45 | -------------------------------------------------------------------------------- /lib/douban.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Douban API v2 SDK 3 | */ 4 | var util = require('util'); 5 | var mo_url = require('url'); 6 | var error = require('debug')('dbj:douban:error') 7 | 8 | function OAuth2(clientId, clientSecret, customHeaders) { 9 | this._clientId= clientId; 10 | this._clientSecret= clientSecret; 11 | this._baseSite= 'https://www.douban.com'; 12 | this._authorizeUrl= "/service/auth2/auth"; 13 | this._accessTokenUrl= "/service/auth2/token"; 14 | this._accessTokenName= "access_token"; 15 | this._authMethod= "Bearer"; 16 | this._customHeaders = {}; 17 | } 18 | util.inherits(OAuth2, require('oauth').OAuth2); 19 | 20 | OAuth2.prototype.clientFromToken = function(token) { 21 | var client = new Client(token, this); 22 | return client; 23 | }; 24 | 25 | var SEVEN_DAYS = 60 * 60 * 24 * 7; 26 | OAuth2.prototype.getToken = function(code, params, callback) { 27 | this.getOAuthAccessToken(code, params, function(err, access_token, refresh_token, results) { 28 | if (err) return callback(err); 29 | results = results || {}; 30 | results.access_token = access_token; 31 | results.refresh_token = refresh_token; 32 | // 30 seconds is time gap of douban server and local server 33 | results.expire_date = new Date((+new Date() + (results.expires_in || SEVEN_DAYS) - 30) * 1000); 34 | return callback(null, results); 35 | }); 36 | }; 37 | 38 | /* 39 | * get request client from token 40 | */ 41 | function Client(token, oauth2) { 42 | this.init(token); 43 | this.oauth2 = oauth2; 44 | this._accessTokenName = 'access_token'; 45 | this._customHeaders = {}; 46 | } 47 | 48 | Client.prototype.init = function(token) { 49 | token = token || {}; 50 | this.access_token = token.access_token; 51 | this.refresh_token = token.refresh_token; 52 | this.user_id = token.douban_user_id; 53 | this.expire_date = token.expire_date; 54 | 55 | this._base_url = 'https://api.douban.com'; 56 | }; 57 | 58 | Client.prototype._request = OAuth2.prototype._request; 59 | Client.prototype._executeRequest = OAuth2.prototype._executeRequest; 60 | 61 | Client.prototype._auth_headers = function() { 62 | if (!this.access_token) return {}; 63 | return { 64 | 'Authorization': 'Bearer ' + this.access_token 65 | }; 66 | }; 67 | 68 | Client.prototype.isExpired = function() { 69 | return this.dead || this.expire_date < new Date(); 70 | }; 71 | 72 | Client.prototype.request = function(method, url, data, callback) { 73 | var self = this; 74 | 75 | if (typeof data === 'function') { 76 | callback = data; 77 | data = ''; 78 | } 79 | 80 | method = method.toUpperCase(); 81 | 82 | var parsed_url = mo_url.parse(self._base_url + url); 83 | 84 | var qs = parsed_url.query || {}; 85 | if (self.oauth2) { 86 | qs.apikey = qs.client_id = self.oauth2._clientId; 87 | } 88 | if (method === 'GET' && data) { 89 | for (var k in data) { 90 | qs[k] = data[k]; 91 | } 92 | data = ''; 93 | } 94 | parsed_url.query = qs; 95 | 96 | function run() { 97 | self.oauth2._request(method, mo_url.format(parsed_url), 98 | self._auth_headers(), data, self.access_token || '', function(err, body, res) { 99 | if (err) return callback(err); 100 | var ret = {} 101 | try { 102 | ret = JSON.parse(body); 103 | } catch (e) { 104 | //error('Error parsing json: %s', body) 105 | return callback(e, ret, res) 106 | } 107 | callback(err, ret, res); 108 | }); 109 | } 110 | 111 | // if has expired 112 | if (self.isExpired()) { 113 | self.refresh(function(err, new_token) { 114 | if (err || !new_token) { 115 | self.dead = true; 116 | return; 117 | } 118 | self.init(new_token) = new_token; 119 | run(); 120 | }); 121 | } else { 122 | run(); 123 | } 124 | }; 125 | 126 | Client.prototype.refresh = function(cb) { 127 | var self = this; 128 | this.oauth2.getToken(self.refresh_token, { 129 | grand_type: 'refresh_token', 130 | }, function(err, new_token) { 131 | if (err) { 132 | self.emit('refresh_error', error); 133 | } else { 134 | self.emit('refresh', new_token); 135 | } 136 | return cb(err, new_token); 137 | }); 138 | }; 139 | 140 | ['get', 'delete', 'head'].forEach(function(item) { 141 | Client.prototype[item] = function(url, callback) { 142 | this.request(item, url, "", callback); 143 | }; 144 | }); 145 | ['post', 'put'].forEach(function(item) { 146 | Client.prototype[item] = function(url, data, callback) { 147 | this.request(item, url, data, callback); 148 | }; 149 | }); 150 | 151 | module.exports = { 152 | Client: Client, 153 | OAuth2: OAuth2 154 | }; 155 | -------------------------------------------------------------------------------- /lib/ip2geo.js: -------------------------------------------------------------------------------- 1 | // IP address to GeoInfo 2 | var satelize = require('satelize'); 3 | 4 | exports.express = function() { 5 | return function(req, res, next) { 6 | // fallback to a Chinese IP address, just in case the client 7 | // didn't send an IP. 8 | var ip = req.headers['x-forwarded-for'] || '211.147.4.49'; 9 | satelize.satelize({ ip: ip }, function(err, payload) { 10 | req.ipgeo = payload || { timezone: 'Asia/Shanghai' }; 11 | next(); 12 | }) 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /lib/mongo/index.js: -------------------------------------------------------------------------------- 1 | var pool = require('./pool'); 2 | var mongo = pool.instant; 3 | 4 | mongo.pool = pool; 5 | mongo.Model = require('./model'); 6 | 7 | mongo.queue = require('../task').queue(pool, 2); 8 | 9 | module.exports = mongo; 10 | -------------------------------------------------------------------------------- /lib/mongo/pool.js: -------------------------------------------------------------------------------- 1 | // a mongodb model, like mongoose 2 | var debug = require('debug'); 3 | var log = debug('dbj:mongo:info'); 4 | var verbose = debug('dbj:mongo:verbose'); 5 | var error = debug('dbj:mongo:error'); 6 | 7 | var gpool = require('generic-pool'); 8 | var mongo = require('mongodb'); 9 | 10 | var cwd = process.cwd(); 11 | var conf = require(cwd + '/conf'); 12 | var utils = require(cwd + '/lib/utils'); 13 | 14 | var noop = utils.noop; 15 | 16 | var mongo_pool = gpool.createPool({ 17 | name: 'mongo', 18 | create: connect_db, 19 | destroy: function(db) { db.close(); }, 20 | max: 4, 21 | //min: 1, // always keep at least one idle connection 22 | priorityRange: 6, 23 | log: false, 24 | //log: verbose 25 | }); 26 | 27 | var db; 28 | function add_task(fn) { 29 | if (db && !db.closed) return fn(db, noop); 30 | connect_db(function(err, client) { 31 | db = add_task.db = client; 32 | fn(db, noop); 33 | }); 34 | } 35 | 36 | process.on('exit', function() { 37 | db && db.close(); 38 | }); 39 | 40 | /** 41 | * To connect the mongodb databse 42 | */ 43 | function connect_db(callback, throw_err) { 44 | var server, db; 45 | 46 | function parse_server(uri) { 47 | var tmp = uri.split(':'); 48 | return new mongo.Server(tmp[0], tmp[1] || mongo.Connection.DEFAULT_PORT); 49 | } 50 | 51 | if (conf.mongo.url) { 52 | mongo.MongoClient.connect(conf.mongo.url, callback); 53 | } else { 54 | if (conf.mongo.servers.length === 1) { 55 | server = parse_server(conf.mongo.servers[0]); 56 | } else { 57 | var ss = []; 58 | conf.mongo.servers.forEach(function(item) { 59 | ss.push(parse_server(item)); 60 | }); 61 | server = new mongo.ReplSetServers(ss); 62 | } 63 | db = new mongo.Db(conf.mongo.dbname, server, { w: 1 }); 64 | db.open(function(err, mongoClient) { 65 | if (err !== null) { 66 | error('db open failed: %s', err); 67 | console.trace(err); 68 | if (throw_err) throw err; 69 | //return callback(err, db); 70 | } 71 | 72 | if (conf.mongo.username) { 73 | db.authenticate(conf.mongo.username, conf.mongo.password, function(err, result) { 74 | verbose('mongodb pool connected database'); 75 | callback(err, mongoClient); 76 | }); 77 | } else { 78 | verbose('mongodb pool connected database'); 79 | callback(err, mongoClient); 80 | } 81 | }); 82 | db.on('close', function() { 83 | db.closed = true; 84 | }); 85 | } 86 | } 87 | // set up a database connection at start, 88 | // throw err if connection failed 89 | connect_db(function(err, _db) { 90 | db = _db; 91 | // prepare database, ensure indexes 92 | require(cwd + '/database')(_db, function(err) { 93 | if (err) error('Database preparation failed...', err); 94 | log('database is ready.'); 95 | }); 96 | }, true); 97 | 98 | module.exports = mongo_pool; 99 | module.exports.instant = add_task; 100 | -------------------------------------------------------------------------------- /lib/passport.js: -------------------------------------------------------------------------------- 1 | var passport = require('passport'); 2 | var douban = require('passport-douban'); 3 | var local = passport.local = require('passport-local'); 4 | 5 | var conf = require('../conf/'); 6 | var User = require('../models/user'); 7 | 8 | var doubanStratege = new douban.Strategy({ 9 | clientID: conf.douban.key, 10 | clientSecret: conf.douban.secret, 11 | callbackURL: conf.site_root + '/auth/douban/callback' 12 | }, associate_douban); 13 | 14 | function associate_douban(access_token, refresh_token, params, profile, done) { 15 | var data = profile._json; 16 | 17 | params.expire_date = new Date((+new Date() + (params.expires_in) - 30) * 1000), 18 | params.refresh_token = refresh_token; 19 | 20 | data.douban_token = params; 21 | 22 | var user = new User({ _id: profile.id }); 23 | 24 | user.merge(data, done); 25 | } 26 | 27 | passport.use(new local.Strategy(User.getByPasswd)); 28 | passport.use(doubanStratege); 29 | 30 | passport.serializeUser(function(user, done) { 31 | done(null, user.id || user._id); 32 | }); 33 | 34 | passport.deserializeUser(User.getFromMongo); 35 | 36 | module.exports = passport; 37 | -------------------------------------------------------------------------------- /lib/raven.js: -------------------------------------------------------------------------------- 1 | /** 2 | * To send messages to Sentry. 3 | */ 4 | var conf = require('../conf'); 5 | 6 | var debug = require('debug'); 7 | var verbose = debug('dbj:raven:verbose'); 8 | var message = _message = debug('dbj:raven:message'); 9 | var error = _error = debug('dbj:raven:error'); 10 | 11 | var client; 12 | 13 | if (conf.raven) { 14 | var raven = require('raven'); 15 | client = new raven.Client(conf.raven, { 16 | logger: process.env.USER || 'general', 17 | site: conf.site_name, 18 | }); 19 | 20 | message = function(msg) { 21 | var args = [].slice.apply(arguments); 22 | 23 | _message.apply(debug, args); 24 | 25 | if (!client._enabled) return; 26 | 27 | setImmediate(function() { 28 | args.shift(); 29 | 30 | if (msg.indexOf('%s') !== -1) { 31 | msg = msg.replace(/\%s/g, function() { 32 | return args.shift(); 33 | }); 34 | } 35 | 36 | var extra = tags = null; 37 | if (args.length) { 38 | extra = args[args.length - 1]; 39 | args.pop(); 40 | } 41 | if (extra && typeof extra !== 'object') { 42 | extra = { extra: extra }; 43 | } 44 | if (extra) { 45 | tags = extra.tags; 46 | delete extra.tags; 47 | } 48 | var level = 'info'; 49 | if (extra && 'level' in extra) { 50 | tags = tags || {}; 51 | level = extra.level || level; 52 | tags.level = level; 53 | delete extra.level; 54 | } 55 | 56 | //console.log(level, tags, extra); 57 | client.captureMessage(msg, { 58 | level: level, 59 | tags: tags, 60 | extra: extra, 61 | }); 62 | }); 63 | }; 64 | error = function(err) { 65 | var args = [].slice.apply(arguments); 66 | 67 | if (!client._enabled) return; 68 | 69 | if (typeof err === 'number') err = String(err); 70 | if (typeof err === 'string') { 71 | if (err.indexOf('%s') !== -1) { 72 | err = err.replace(/\%s/g, function() { 73 | return args.shift(); 74 | }); 75 | } 76 | err = new Error(err); 77 | } 78 | args[0] = err; 79 | 80 | setImmediate(function() { 81 | client.captureError.apply(client, args); 82 | }); 83 | }; 84 | 85 | client.on('logged', function(){ 86 | _message('Sent raven message.'); 87 | }); 88 | client.on('error', function(e){ 89 | _error('sentry is broken: %s', e); 90 | }) 91 | } else { 92 | // just keep silent 93 | message = error = function() {} 94 | } 95 | 96 | module.exports = { 97 | client: client, 98 | message: message, 99 | error: error, 100 | express: function () {} 101 | }; 102 | -------------------------------------------------------------------------------- /lib/redis/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Redis client wrapper 3 | */ 4 | var Redis = require('redis'); 5 | var Cached = require('redis-cached'); 6 | 7 | module.exports = function(opts) { 8 | var prefix = opts.prefix; 9 | var client = Redis.createClient({ 10 | url: opts.url, 11 | port: opts.port, 12 | host: opts.host, 13 | detect_buffers: true 14 | }); 15 | var cached = Cached(client, { 16 | ttl: opts.ttl, 17 | prefix: prefix 18 | }); 19 | 20 | return { 21 | Redis: Redis, 22 | rd: client, 23 | client: client, 24 | clear: function(key, cb) { 25 | client.del(prefix + key, cb); 26 | }, 27 | cached: cached 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /lib/task.js: -------------------------------------------------------------------------------- 1 | /* 2 | * task pools 3 | */ 4 | var debug = require('debug'); 5 | var log = debug('dbj:pool:info'); 6 | var error = debug('dbj:pool:error'); 7 | 8 | var gpool = require('generic-pool'); 9 | const decorator = require('generic-pool-decorator') 10 | var douban = require('./douban'); 11 | 12 | var conf = require(process.cwd() + '/conf'); 13 | 14 | var mores = conf.douban_more || [ conf.douban ]; 15 | 16 | var i_tick = 0; 17 | var n_mores = mores.length; 18 | var n_main = 2; 19 | 20 | function oauth2_item(item) { 21 | var is_specified = true; 22 | if (!item) { 23 | item = mores[i_tick]; 24 | i_tick++; 25 | if (i_tick >= n_mores) i_tick = 0; 26 | is_specified = false; 27 | } 28 | var ret = new douban.OAuth2(item.key, item.secret); 29 | // default to maximium 10 request per limit 30 | var req_delay = 60000 / (item.limit || 10); 31 | if (is_specified) { 32 | req_delay = req_delay * n_main; 33 | } else { 34 | req_delay = req_delay / n_mores; 35 | } 36 | log('OAuth2 client request delay: %ss', req_delay / 1000) 37 | ret.req_delay = req_delay; 38 | return ret; 39 | } 40 | 41 | // http(s) request pool, mainly for douban api 42 | var api_pool = gpool.createPool({ 43 | name: 'api', 44 | create: function(callback) { 45 | var oauth2 = oauth2_item(conf.douban); 46 | callback(null, oauth2); 47 | }, 48 | destroy: function() { }, 49 | // 主请求池允许开多个 client ,防止单个请求挂起时影响后续所有请求 50 | max: n_main, 51 | //min: 1, 52 | priorityRange: 6, 53 | //log: conf.debug ? log : false 54 | }); 55 | 56 | var api_pool2 = gpool.createPool({ 57 | name: 'api', 58 | create: function(callback) { 59 | var oauth2 = oauth2_item(); 60 | callback(null, oauth2); 61 | }, 62 | destroy: function() { }, 63 | max: n_mores, 64 | //min: 1, 65 | priorityRange: 6, 66 | //log: conf.debug ? log : false 67 | }); 68 | 69 | var computings = { n: 0 }; 70 | var compute_pool = gpool.createPool({ 71 | name: 'compute', 72 | create: function(callback) { 73 | computings.n++; 74 | callback(null, computings); 75 | }, 76 | destroy: function() { computings.n--; }, 77 | max: 3, 78 | priorityRange: 6, 79 | }); 80 | compute_pool.pooled = decorator(compute_pool); 81 | 82 | function queue(pool, default_priority) { 83 | return function(fn, priority) { 84 | pool.acquire(function(err, client) { 85 | // `fn` defination is like `fn(db, next)`; 86 | if (fn.length === 2) { 87 | //log('async calling job'); 88 | fn(client, function(err) { 89 | if (err) error('async job:\n%s\nfailed because:\n%s', job.toString(), err); 90 | pool.release(client); 91 | }); 92 | } else { 93 | fn(client); 94 | pool.release(client); 95 | } 96 | }, typeof priority === 'undefined' ? default_priority : priority); 97 | } 98 | } 99 | 100 | module.exports = { 101 | api_pool: api_pool, 102 | api_pool2: api_pool2, 103 | compute_pool: compute_pool, 104 | compute: queue(compute_pool), 105 | api: queue(api_pool, 3), // default priority is 3 106 | api2: queue(api_pool2, 3), 107 | API_REQ_DELAY: 60000 / (conf.douban.limit || 10), 108 | API_REQ_PERPAGE: 100, 109 | queue: queue 110 | }; 111 | -------------------------------------------------------------------------------- /lib/template/consts.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktmud/doubanj/f7b5cc08fcb878ec655def7df7c7ff20244cdce9/lib/template/consts.js -------------------------------------------------------------------------------- /lib/template/helpers.js: -------------------------------------------------------------------------------- 1 | var central = require('../central'); 2 | var utils = central.utils; 3 | var assets = central.assets; 4 | 5 | var helpers = { 6 | conf: central.conf, 7 | trunc: utils.trunc, 8 | simple_trunc: utils.simple_trunc, 9 | strftime: function(format, time) { 10 | // `this` is the `locals` 11 | var timezone = this.req.ipgeo.timezone; 12 | return utils.strftime(format, time, timezone); 13 | }, 14 | time_elapse: utils.time_elapse, 15 | chinese_period: utils.chinese_period, 16 | urlmap: function() { 17 | try { 18 | return JSON.stringify(assets.urlMap.apply(this, arguments)); 19 | } catch (e) {} 20 | return ''; 21 | }, 22 | hashmap: function() { 23 | try { 24 | return JSON.stringify(assets.hashMap.apply(this, arguments)); 25 | } catch (e) {} 26 | return ''; 27 | }, 28 | filehash: assets.getHash, 29 | istatic: assets.istatic.serve(), 30 | }; 31 | 32 | utils.extend(helpers, utils.timeformats); 33 | 34 | module.exports = helpers; 35 | 36 | -------------------------------------------------------------------------------- /lib/utils/index.js: -------------------------------------------------------------------------------- 1 | var lodash = require('lodash'); 2 | 3 | var utils = { 4 | noop: function(){}, 5 | douimg: function douimg(url) { 6 | // douban changed its CDN domain, this is for avoiding 7 | // updating our database records 8 | return url.replace(/(img[0-9])\.douban\.com/, '$1.doubanio.com'); 9 | }, 10 | lodash: lodash, 11 | forEach: lodash.forEach, 12 | defaults: lodash.defaults, 13 | extend: lodash.extend, 14 | shuffle: lodash.shuffle, 15 | union: lodash.union, 16 | difference: lodash.difference, 17 | }; 18 | 19 | module.exports = utils; 20 | 21 | lodash.extend(utils, require('./strftime'), require('./text')) 22 | -------------------------------------------------------------------------------- /lib/utils/strftime.js: -------------------------------------------------------------------------------- 1 | var tz = require('timezone/loaded'); 2 | 3 | function chinese_period(timediff, no_seconds) { 4 | // strip the miliseconds 5 | timediff = Math.round(timediff / 1000); 6 | 7 | // get seconds 8 | var sec = timediff % 60; 9 | // remove seconds from the date 10 | timediff = Math.floor(timediff / 60); 11 | 12 | // get minutes 13 | var mi = timediff % 60; 14 | // remove minutes from the date 15 | timediff = Math.floor(timediff / 60); 16 | 17 | // get hours 18 | var h = timediff % 24; 19 | // remove hours from the date 20 | timediff = Math.floor(timediff / 24); 21 | 22 | // the rest of timediff is number of days 23 | var d = timediff; 24 | return (d ? d + '天' : '') + 25 | (h ? h + '小时' : '') + 26 | (mi ? (no_seconds ? Math.round(mi) : mi) + '分' + (no_seconds ? '钟' : '') : '') + 27 | (!no_seconds ? sec + '秒' : ''); 28 | } 29 | 30 | exports.timeformats = { 31 | FULL_TIME: '%Y年%m月%d日 %H:%M:%S', 32 | FULL_TIME_1: '%Y年%m月%d日%p%I点%M分%S秒', 33 | DIGIT_TIME: '%Y-%m-%d %H:%M:%S', 34 | }; 35 | 36 | exports.strftime = function(format, time, timezone) { 37 | // language is always set to Chinese 38 | return tz(time, format, 'zh_CN', timezone); 39 | }; 40 | 41 | exports.chinese_period = chinese_period; 42 | 43 | exports.time_elapse = function time_elapse(date, now) { 44 | now = now || new Date(); 45 | var y = now.getFullYear() - date.getFullYear(); 46 | var m = now.getMonth() - date.getMonth(); 47 | if (m < 0) { 48 | y--; 49 | m = m + 12; 50 | } 51 | 52 | date.setFullYear(date.getFullYear() + y); 53 | date.setMonth(date.getMonth() + m); 54 | 55 | var timediff = now - date; 56 | if (timediff < 0 ) { 57 | m--; 58 | var mo = date.getMonth(); 59 | if (mo == 1) { 60 | date.setFullYear(date.getFullYear() - 1); 61 | date.setMonth(12); 62 | } else { 63 | date.setMonth(mo - 1); 64 | } 65 | timediff = now - date; 66 | } 67 | if (m < 0) m = 0; 68 | 69 | return (y ? y + '年' : '') + 70 | (m ? m + '个月' : '') + 71 | chinese_period(timediff); 72 | }; 73 | -------------------------------------------------------------------------------- /lib/utils/text.js: -------------------------------------------------------------------------------- 1 | function realen(str) { 2 | var l = str.length 3 | return l; 4 | } 5 | 6 | function simple_trunc(str, limit) { 7 | var l = str.length; 8 | if (l > limit) { 9 | return str.slice(0, limit - 3) + '..'; 10 | } 11 | return str; 12 | } 13 | // double width characters trunc 14 | function trunc(str, limit, eclipsis) { 15 | if (typeof eclipsis === 'undefined') { 16 | eclipsis = '..'; 17 | } 18 | var l = str.length; 19 | if (l * 2 < limit) return str; 20 | var i = 0; 21 | limit = limit * 2; 22 | while (limit > 0) { 23 | limit--; 24 | if (str.charCodeAt(i) > 2000) limit--; 25 | i++; 26 | } 27 | return str.slice(0, i) + (i < l ? eclipsis : ''); 28 | } 29 | 30 | module.exports = { 31 | trunc: trunc, 32 | simple_trunc: simple_trunc, 33 | }; 34 | -------------------------------------------------------------------------------- /models/consts.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | KEY_CLICK_BOOK_SCORE: 'click_book_scores', 3 | 4 | DOUBAN_APPS: ['book', 'movie', 'music'], 5 | USER_COLLECTION: 'user', 6 | INTEREST_STATUS_ORDERED: { 7 | book: ['wish', 'ing', 'done'], 8 | }, 9 | INTEREST_STATUS_LABELS: { 10 | book: { 11 | wish: '想读', 12 | ing: '在读', 13 | done: '读过' 14 | }, 15 | }, 16 | INTEREST_STATUSES: { 17 | book: { 18 | wish: 'wish', 19 | ing: 'reading', 20 | done: 'read' 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /models/interest/base.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Interest Base Class 3 | */ 4 | var debug = require('debug'); 5 | var util = require('util'); 6 | var log = debug('dbj:interest:info'); 7 | var verbose = debug('dbj:interest:verbose'); 8 | var error = debug('dbj:interest:error'); 9 | 10 | var conf = central.conf; 11 | var mongo = central.mongo; 12 | //var redis = central.redis; 13 | var utils = central.utils; 14 | 15 | var Subject = require('../subject'); 16 | 17 | var consts = require('../consts'); 18 | var DOUBAN_APPS = consts.DOUBAN_APPS; 19 | var INTEREST_STATUS_LABELS = consts.INTEREST_STATUS_LABELS; 20 | var APP_DATA_DEFAULT = { 21 | n_interest: 0 22 | }; 23 | 24 | function Interest(info) { 25 | if (!(this instanceof Interest)) return new Interest(info); 26 | 27 | var self = this; 28 | utils.extend(self, info); 29 | //for (var i in info) { 30 | //this[i] = info[i]; 31 | //} 32 | 33 | return this; 34 | } 35 | 36 | /** 37 | * Inherited functions: 38 | */ 39 | util.inherits(Interest, mongo.Model); 40 | utils.extend(Interest, mongo.Model); 41 | 42 | Interest.prototype.kind = Interest._collection = 'interest'; 43 | Interest.prototype.subject_type = 'general'; 44 | 45 | Interest._cache_ttl = 36000; 46 | Interest._default_sort = { 'updated': -1 }; 47 | //Interest.get = redis.cached.wrap(mongo.Model.get); 48 | 49 | Interest.findByUser = function(uid, opts, cb) { 50 | verbose('getting interests for [%s]: ', uid, opts); 51 | 52 | var query = { user_id: uid }; 53 | 54 | if (opts.query) { 55 | utils.extend(query, opts.query); 56 | delete opts.query; 57 | } 58 | 59 | return this.find(query, opts, cb); 60 | }; 61 | 62 | /** 63 | * Attach book subject 64 | */ 65 | Interest.book_attached = Interest.attached('subject_id', 'subject', Subject.book); 66 | //Interest.movie_attached = Interest.attached('subject_id', Subject.movie); 67 | 68 | Interest.prototype.toObject = function() { 69 | var self = this; 70 | var now = new Date(); 71 | var ret = { 72 | '_id': self['_id'], 73 | 'id': self['id'], 74 | 'uid': self['uid'] || self['user_id'], 75 | 'user_id': self['user_id'], 76 | 'updated': self['updated'], 77 | 'privacy': self['privacy'], 78 | 'subject_type': self['subject_type'], 79 | 'atime': now 80 | }; 81 | var s_key = ret['subject_type'] + '_id'; 82 | ret[s_key] = self[s_key]; 83 | return ret; 84 | }; 85 | Interest.prototype.subject_ns = function() { 86 | return this.subject_type; 87 | }; 88 | Interest.prototype.status_cn = function(unknown) { 89 | unknown = '' || unknown; 90 | return INTEREST_STATUS_LABELS[this.subject_type][this.status] || unknown; 91 | }; 92 | 93 | Object.defineProperty(Interest.prototype, 'rated', { 94 | get: function() { 95 | return this.rating && this.rating.value; 96 | }, 97 | enumerable: false 98 | }); 99 | 100 | module.exports = Interest; 101 | -------------------------------------------------------------------------------- /models/interest/book.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Book Interest Class 3 | */ 4 | var util = require('util'); 5 | var utils = central.utils; 6 | 7 | var Interest = require('./base'); 8 | var BookSubject = require('../subject').book; 9 | 10 | 11 | function BookInterest(info) { 12 | if (!(this instanceof BookInterest)) return new BookInterest(info); 13 | 14 | var self = this; 15 | 16 | if ('subject' in info) { 17 | info.subject = BookSubject(info.subject); 18 | } 19 | 20 | utils.extend(self, info); 21 | 22 | return this; 23 | } 24 | util.inherits(BookInterest, Interest); 25 | utils.extend(BookInterest, Interest); 26 | 27 | BookInterest.prototype.kind = BookInterest._collection = 'book_interest'; 28 | BookInterest.prototype.subject_type = 'book'; 29 | BookInterest.prototype.ns = 'book'; 30 | 31 | module.exports = BookInterest; 32 | -------------------------------------------------------------------------------- /models/interest/index.js: -------------------------------------------------------------------------------- 1 | var Base = require('./base'); 2 | 3 | module.exports = Base; 4 | 5 | ['book'].forEach(function(item) { 6 | var cls = require('./' + item); 7 | //central.redis.cached.register(cls); 8 | 9 | module.exports[item] = cls; 10 | }); 11 | -------------------------------------------------------------------------------- /models/mixins/data.js: -------------------------------------------------------------------------------- 1 | var central = require('../../lib/central'); 2 | 3 | var redis = central.redis; 4 | 5 | var noop = function(){}; 6 | 7 | /** 8 | * format a cache key 9 | */ 10 | exports.cache_key = function(key) { 11 | var self = this; 12 | if (Array.isArray(key)) { 13 | return key.map(function(item) { return self.cache_key(item); }); 14 | } 15 | var ret = ['dbj', this.kind, this.id]; 16 | if (key) ret.push(key); 17 | return ret.join(':'); 18 | }; 19 | 20 | /** 21 | * Get or set a redis-stored data 22 | */ 23 | exports.data = function(key, val, cb) { 24 | if (typeof val === 'function') return this._get_data(key, val); 25 | return this._set_data.apply(this, arguments); 26 | }; 27 | 28 | exports._get_data = function(key, cb) { 29 | var key = this.cache_key(key); 30 | redis.client.get(key, function(err, buf) { 31 | try { 32 | if (buf) { 33 | buf = JSON.parse(buf); 34 | } 35 | } catch (e) { 36 | buf = null; 37 | } 38 | return cb(err, buf); 39 | }); 40 | }; 41 | exports._set_data = function(key, val, cb) { 42 | key = this.cache_key(key); 43 | val = JSON.stringify(val); 44 | redis.client.set(key, val, cb || noop); 45 | }; 46 | exports._del_data = function(key, cb) { 47 | key = this.cache_key(key); 48 | redis.client.del(key, cb || noop); 49 | }; 50 | 51 | exports.expire = function(key, milliseconds, cb) { 52 | redis.client.pexpire(this.cache_key(key), milliseconds, cb || noop); 53 | }; 54 | -------------------------------------------------------------------------------- /models/subject/base.js: -------------------------------------------------------------------------------- 1 | var debug = require('debug'); 2 | var util = require('util'); 3 | var log = debug('dbj:subject:info'); 4 | var error = debug('dbj:subject:error'); 5 | 6 | var cwd = central.cwd; 7 | var conf = central.conf; 8 | var task = central.task; 9 | var mongo = central.mongo; 10 | var utils = central.utils; 11 | 12 | function Subject(info) { 13 | if (!(this instanceof Subject)) return new Subject(info); 14 | 15 | utils.extend(this, info); 16 | this.prop_keys = Object.keys(info); 17 | 18 | return this; 19 | } 20 | util.inherits(Subject, mongo.Model); 21 | utils.extend(Subject, mongo.Model); 22 | 23 | Subject.prototype.kind = Subject._collection = 'subject'; 24 | 25 | Subject.prototype.toObject = function() { 26 | var self = this; 27 | var now = new Date(); 28 | var ret = { 29 | atime: now, 30 | mtime: self.mtime, 31 | }; 32 | self.prop_keys.forEach(function(k) { 33 | ret[k] = self[k]; 34 | }); 35 | return ret; 36 | }; 37 | Subject.prototype.db_url = function() { 38 | return 'http://' + this.kind + '.douban.com/subject/' + this.id + '/'; 39 | }; 40 | 41 | module.exports = Subject; 42 | -------------------------------------------------------------------------------- /models/subject/book.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var utils = central.utils; 3 | 4 | var Subject = require('./base'); 5 | 6 | function Book(info) { 7 | if (!(this instanceof Book)) return new Book(info); 8 | 9 | var self = this; 10 | 11 | // fix douban img url 12 | if (info.images) { 13 | utils.forEach(info.images, function(item, key) { 14 | info.images[key] = utils.douimg(item) 15 | }) 16 | } 17 | info.type = info.subject_type = 'book'; 18 | 19 | utils.extend(self, info); 20 | 21 | Object.defineProperty(this, 'prop_keys', { 22 | value: Object.keys(info), 23 | enumerable: false 24 | }); 25 | 26 | return this; 27 | } 28 | util.inherits(Book, Subject); 29 | utils.extend(Book, Subject); 30 | 31 | var gets = Book.gets; 32 | Book.gets = function(ids, opts, cb) { 33 | if (typeof opts === 'object') { 34 | switch(opts.fields) { 35 | case 'simple': 36 | opts.fields = null; 37 | opts.fields = { 38 | title: 1, 39 | 'images': 1, 40 | author: 1, 41 | publisher: 1, 42 | translator: 1, 43 | rated: 1, 44 | } 45 | break; 46 | } 47 | } 48 | gets.call(Book, ids, opts, cb); 49 | }; 50 | 51 | Book.prototype.kind = Book._collection = 'book'; 52 | 53 | module.exports = Book; 54 | -------------------------------------------------------------------------------- /models/subject/index.js: -------------------------------------------------------------------------------- 1 | var Base = require('./base'); 2 | 3 | module.exports = Base; 4 | 5 | ['book'].forEach(function(item) { 6 | var cls = require('./' + item); 7 | //central.redis.cached.register(cls); 8 | 9 | module.exports[item] = cls; 10 | }); 11 | -------------------------------------------------------------------------------- /models/toplist/book.js: -------------------------------------------------------------------------------- 1 | var central = require('../../lib/central') 2 | var _ = require('../../lib/utils') 3 | var cached = require('../../lib/cached') 4 | var getToplistKey = require('../../models/toplist').getToplistKey 5 | var mongo = central.mongo 6 | var verbose = require('debug')('dbj:toplist:verbose') 7 | 8 | var User = require('../user') 9 | 10 | var people_fields = { _id: 1, uid: 1, name: 1, avatar: 1, 'book_stats.all.top_tags': 1, signature: 1 } 11 | 12 | var banned_tags = [ 13 | '耽美', 'BL', '青春文学', '耽美小说', 14 | 'BL漫画', '腐', 15 | '日本文学', '日本', 16 | '推理', '推理小说', '日系推理', '日本推理', 17 | '写真', '写真集', '摄影', 'PHOTOBOOK', '寫真', 18 | '绘本', '童话', '童书', '儿童文学', '图画书', 19 | '轻小说', '輕小說', '漫画与轻小说', 20 | '少年向', '乙女向', 21 | '热血', '恐怖', '穿越', '武侠', '奇幻', '九州', 22 | '经典', '名著', '师太', 23 | '少年漫画', '日漫', '港漫', '国漫', '青年漫画', 24 | '漫画', '日本漫画', '漫畫', 25 | '晋江', '现代都市', '网络小说', 26 | '青春', '言情', '小说', '爱情', '悬疑' 27 | ] 28 | function is_serious_reading(tags) { 29 | var counter = 0 30 | for (var i in tags) { 31 | var item = tags[i] 32 | if (banned_tags.indexOf(item._id) !== -1) { 33 | counter++ 34 | if (counter > 5) return false 35 | } 36 | } 37 | return true 38 | } 39 | 40 | var ONE_HOUR = 3600 41 | 42 | function get_hardest_reader(period, cb) { 43 | var key = getToplistKey('book', period) 44 | var cached_items_key = key + '_cached' 45 | 46 | cached.get(cached_items_key, function(err, items) { 47 | if (items) { 48 | return cb(err, items) 49 | } 50 | // get blacklist (currently, can only set it via redis-client) 51 | cached.get('user_blacklist', function(err, blacklist) { 52 | verbose('Got blacklist: %j', blacklist) 53 | // get ids 54 | cached.get(key, function(err, ids) { 55 | if (ids && ids.length && blacklist && blacklist.length) { 56 | ids = ids.filter(function(item) { 57 | if (~blacklist.indexOf(item._id)) { 58 | return false 59 | } 60 | return true 61 | }) 62 | } 63 | if (err || !ids || !ids.length) { 64 | return cb(err, []) 65 | } 66 | getReal(ids) 67 | }) 68 | }) 69 | }) 70 | 71 | function getReal(ids) { 72 | User.gets(ids, { 73 | fields: people_fields 74 | }, function(err, users) { 75 | if (err || !users) { 76 | return cb(err, []) 77 | } 78 | users = users.filter(function(item, i) { 79 | if (item) { 80 | try { 81 | // there are useless type of books in he/she's collection 82 | if (is_serious_reading(item.book_stats.all.top_tags.slice(0,12))) { 83 | item.book_quote_n = ids[i].value 84 | return true 85 | } 86 | } catch (e) {} 87 | } 88 | return false 89 | }) 90 | users = users.slice(0, 99) 91 | cb(err, users) 92 | cached.set(cached_items_key, users, function(){}) 93 | }) 94 | } 95 | 96 | } 97 | 98 | exports.hardest_reader = get_hardest_reader 99 | -------------------------------------------------------------------------------- /models/toplist/index.js: -------------------------------------------------------------------------------- 1 | exports.getToplistKey = function(namespace, period) { 2 | return namespace + '_done_count_' + period 3 | } 4 | 5 | exports.tops_by_tag = function tops_by_tag(tagname, obj_name, options, cb) { 6 | if (typeof options === 'function') { 7 | cb = options 8 | options = {} 9 | } 10 | 11 | options = options || {} 12 | 13 | var start = options.start || 0 14 | var limit = options.limit || 24 15 | 16 | central.mongo(function(db) { 17 | db.collection(['top', obj_name, 'by_tag'].join('_')) 18 | .find({ tagname: tagname }, { 19 | sort: { count: -1 }, 20 | fields: { count: 1 }, 21 | limit: limit, 22 | skip: start 23 | }).toArray(function(err, results) { 24 | if (err) return cb(err) 25 | 26 | results.forEach(function(item) { 27 | var tmp = item._id.split('::') 28 | item._id = tmp[0] 29 | // 在自身所有标签里的排名 30 | item.self_order = tmp[1] 31 | }) 32 | 33 | return cb(err, results) 34 | }) 35 | }) 36 | } 37 | 38 | exports.book = require('./book') 39 | -------------------------------------------------------------------------------- /models/user/click.js: -------------------------------------------------------------------------------- 1 | var redis = central.redis; 2 | var User = require('./index') 3 | 4 | // 过期时间,单位 秒 5 | var CLICK_EXPIRES = 60 * 60 * 12; 6 | 7 | if (central.conf.debug) { 8 | CLICK_EXPIRES = 30; 9 | } 10 | 11 | var grades = { 12 | 0: '话不投机', 13 | 100: '形同陌路', 14 | 300: '貌合神离', 15 | 600: '志同道合', 16 | 800: '情投意合', 17 | 1200: '情同手足', 18 | 1600: '心有灵犀', 19 | 2000: '同甘共苦', 20 | 3000: '生死与共', 21 | }; 22 | 23 | 24 | /** 25 | * progress of calculating click 26 | */ 27 | User.prototype.clickProgress = function(other, cb) { 28 | if (!(other instanceof this.constructor)) return null; 29 | this.getClick(other, function(err, r) { 30 | if (err) return cb(err); 31 | if (!r) return cb(null, 0); 32 | return cb(null, r.p); 33 | }); 34 | }; 35 | 36 | /** 37 | * return a click grading text 38 | */ 39 | User.prototype.clickGrade = function(num) { 40 | num = parseInt(num); 41 | for (var k in grades) { 42 | if (parseInt(k) < num) continue; 43 | return grades[k]; 44 | } 45 | return '爆表'; 46 | }; 47 | 48 | function swap(obj, a, b) { 49 | var tmp1 = obj[a], tmp2 = obj[b]; 50 | obj[b] = tmp1; 51 | obj[a] = tmp2; 52 | } 53 | 54 | /** 55 | * Get click for the other one 56 | */ 57 | User.prototype.getClick = function(other, cb) { 58 | var self = this; 59 | redis.client.get(self._clickKey(other), function(err, ret) { 60 | try { 61 | ret = JSON.parse(ret); 62 | } catch (e) {} 63 | //ret = null; 64 | if (!err && ret && typeof ret.ratios === 'object') { 65 | // 这种比较与计算结果时的 sort 一致 66 | if (self.id > other.id) { 67 | swap(ret, 'love_hate', 'hate_love'); 68 | swap(ret, 'done_wish', 'wish_done'); 69 | for (var k in ret.ratios) { 70 | swap(ret.ratios[k], 0, 1); 71 | } 72 | } 73 | ret.score = ret.score || ret.index || '0'; 74 | } 75 | return cb(err, ret); 76 | }); 77 | }; 78 | User.prototype.setClick = function(other, val, cb) { 79 | var self = this; 80 | var key = self._clickKey(other); 81 | redis.client.set(key, JSON.stringify(val), cb); 82 | redis.client.expire(key, val && val.expires_in || CLICK_EXPIRES); 83 | }; 84 | User.prototype.click_url = function(other, tail) { 85 | var other_uid = typeof other !== 'object' ? other : (other.uid || other.id); 86 | return this.url() + 'click/' + other_uid + (tail || ''); 87 | }; 88 | 89 | User.prototype._clickKey = function(other) { 90 | // key 只需保证唯一,先后顺序其实并不重要 91 | return 'click_' + [this.id, other.id].sort().join('_'); 92 | }; 93 | -------------------------------------------------------------------------------- /models/user/friends.js: -------------------------------------------------------------------------------- 1 | var debug = require('debug') 2 | var async = require('async') 3 | var util = require('util') 4 | var lodash = require('lodash') 5 | 6 | var verbose = debug('dbj:user:friends:verbose') 7 | var log = debug('dbj:user:friends:info') 8 | var error = debug('dbj:user:friends:error') 9 | 10 | var central = require('../../lib/central') 11 | 12 | var cwd = central.cwd 13 | var conf = central.conf 14 | var redis = central.redis 15 | var mongo = central.mongo 16 | var task = central.task 17 | 18 | var consts = require('../consts') 19 | var User = require('./index') 20 | 21 | var ONE_DAY = 60 * 60 * 24 * 100 22 | var FRIENDSHIP_EXPIRES = 10 * ONE_DAY 23 | 24 | 25 | /** 26 | * Get following friends from redis cache 27 | */ 28 | User.prototype.listFollowings = function(query, cb) { 29 | var self = this 30 | self.data('followings', function(err, ids) { 31 | if (err) { 32 | error('get followings from redis failed: ', err) 33 | return cb(err) 34 | } 35 | 36 | var start = parseInt(query.start, 10) || 0 37 | var limit = parseInt(query.limit, 10) || 20 38 | 39 | if (!Array.isArray(ids)) { 40 | ids = [] 41 | } 42 | 43 | var ending = start + limit 44 | 45 | function end(_ids) { 46 | _ids = _ids.slice(start, ending) 47 | cb(null, _ids) 48 | } 49 | 50 | // there's enough ids in local cache. 51 | if (ids.length >= ending) return end(ids) 52 | 53 | self.data('following_sites', function(err, sites) { 54 | var _start = ids.length 55 | if (_start && sites && typeof sites === 'object') { 56 | _start += Object.keys(sites).length 57 | } 58 | log('Pulling followings for [%s] from: %s (current: %s)', self.uid, _start, start) 59 | self.pullFollowings(_start, function(err, _ids) { 60 | if (err) { 61 | error('Pulling followings failed: ', err) 62 | return cb(err) 63 | } 64 | 65 | ids = lodash.union(ids, _ids) 66 | 67 | // save it 68 | self.data('followings', ids, function(err, r) { 69 | verbose('Save pulled followings: (%s, %s)', err, r) 70 | end(ids) 71 | }) 72 | self.expire('followings', FRIENDSHIP_EXPIRES) 73 | }) 74 | }) 75 | }) 76 | } 77 | 78 | /** 79 | * Get following friends list from douban 80 | */ 81 | User.prototype.pullFollowings = function(start, cb) { 82 | var self = this 83 | var cls = self.constructor 84 | 85 | verbose('Pulling followings for %s..', self.name) 86 | 87 | task.api2(function(oauth2, next) { 88 | var uid = self.id || self.uid 89 | oauth2.clientFromToken(self.douban_token).request( 90 | 'GET', '/shuo/v2/users/' + uid + '/following', 91 | { 92 | start: start || 0, 93 | count: 200 // 豆瓣API允许的最大值即为200 94 | }, function(err, ret) { 95 | 96 | // release task client 97 | setTimeout(next, oauth2.req_delay || 0) 98 | 99 | if (err) return cb(err) 100 | 101 | var ids = [], sites = {} 102 | 103 | // save all the users 104 | async.each(ret, function(item, callback) { 105 | if (item.type !== 'user') { 106 | verbose('Skipping none-user %s...', item.screen_name) 107 | sites[item.id] = item.original_site_id 108 | return callback() 109 | } 110 | 111 | ids.push(item.id) 112 | 113 | cls({ _id: item.id }).update({ 114 | name: item.screen_name, 115 | uid: item.uid, 116 | // +800 is the timezone 117 | created: new Date(item.created_at + '+800'), 118 | loc_name: item.city, 119 | signature: item.description, 120 | avatar: item.small_avatar 121 | }, callback) 122 | }, function(err) { 123 | self.data('following_sites', sites, function(err, r) { 124 | verbose('Save pulled followings sites: (%s, %s)', err, r) 125 | cb(err, ids) 126 | }) 127 | self.expire('following_sites', FRIENDSHIP_EXPIRES) 128 | }) 129 | 130 | }) 131 | }) 132 | } 133 | 134 | /** 135 | * clear local cache of followings 136 | */ 137 | User.prototype.clearFollowings = function(cb) { 138 | var self = this 139 | self._del_data(['followings', 'following_sites'], cb) 140 | } 141 | -------------------------------------------------------------------------------- /models/user/interest.js: -------------------------------------------------------------------------------- 1 | var Interest = require('../interest') 2 | var User = require('./index') 3 | var namespaces = ['book'] 4 | 5 | //var redis = central.redis 6 | 7 | function sorted_list(ns, k, sort) { 8 | var list = Interest[ns][ns + '_attached'](function(opts, cb) { 9 | if (typeof opts == 'function') { 10 | cb = opts 11 | opts = {} 12 | } 13 | 14 | var user = this 15 | var uid = user._id 16 | var query = opts.query || {} 17 | if (opts.status && opts.status !== 'all') { 18 | query.status = opts.status 19 | } 20 | Interest[ns].findByUser(uid, { 21 | query: query, 22 | sort: sort, 23 | limit: opts.limit || 30, 24 | skip: opts.start || 0, 25 | }, cb) 26 | }) 27 | 28 | return list 29 | } 30 | 31 | // Please make sure these keys are in indexes, see `database/index.js` 32 | var sorts = { 33 | 'most_commented': { 34 | commented: -1 35 | }, 36 | 'latest': { 37 | updated: -1 38 | }, 39 | 'highest_ratings': { 40 | 'rating.value': -1 41 | } 42 | } 43 | 44 | namespaces.forEach(function(ns, i) { 45 | for (var k in sorts) { 46 | User.prototype[ns + '_' + k] = sorted_list(ns, k, sorts[k]) 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /models/user/progress.js: -------------------------------------------------------------------------------- 1 | var cwd = process.cwd() 2 | var task = require('../../lib/task') 3 | var tasks = require('../../tasks/') 4 | 5 | var User = require('./index') 6 | 7 | var DB_RESPOND_DELAY = 5000 8 | 9 | /** 10 | * reset queue status 11 | */ 12 | User.prototype.reset = function(cb) { 13 | this.update({ 14 | stats_p: 0, 15 | stats_status: null, 16 | book_n: null, 17 | book_synced_n: 0, 18 | last_synced: new Date(), 19 | last_synced_status: 'ing' 20 | }, cb) 21 | } 22 | 23 | /** 24 | * Mark sync start 25 | */ 26 | User.prototype.markSync = function(cb) { 27 | this.update({ 28 | stats_p: 0, 29 | stats_status: null, 30 | last_synced_status: 'ing' 31 | }, cb) 32 | } 33 | 34 | /** 35 | * is syncing or statsing, not finished yet 36 | */ 37 | User.prototype.isIng = function() { 38 | return this.last_synced_status === 'ing' || this.stats_status === 'ing' 39 | } 40 | 41 | /** 42 | * total progress in percent 43 | */ 44 | User.prototype.progress = function() { 45 | var ps = this.progresses() 46 | var n = 0 47 | ps.forEach(function(item) { 48 | n += item 49 | }) 50 | return n 51 | } 52 | /** 53 | * @return {array} 54 | */ 55 | User.prototype.progresses = function() { 56 | var ps = [0, 0] 57 | var user = this 58 | // got douban account info 59 | if (user.created) ps[0] = 5 60 | // starting to sync 61 | if (user.last_synced) ps[0] = 10 62 | 63 | if (user.book_n === 0) { 64 | ps[0] = 80 65 | } else if (user.book_n && user.book_synced_n) { 66 | // only book for now 67 | ps[0] = 10 + Math.ceil((user.book_synced_n / user.book_n) * 70) 68 | } 69 | // 20% percent is for computing 70 | ps[1] = ps[0] >= 80 ? Math.ceil((user.stats_p || 0) * 0.2) : 0 71 | return ps 72 | } 73 | /** 74 | * interval for checking progress 75 | */ 76 | User.prototype.progressInterval = function(remaining, delay) { 77 | var n = this.queue_length() 78 | delay = delay * n 79 | return remaining < 15000 ? DB_RESPOND_DELAY : delay + 4000 80 | } 81 | User.prototype.queue_length = function() { 82 | return tasks.getQueueLength('interest') 83 | } 84 | 85 | User.prototype.isEmpty = function(ns) { 86 | if (!ns) return null 87 | return this[ns+'_n'] === 0 88 | } 89 | 90 | /** 91 | * total remaining 92 | */ 93 | User.prototype.remaining = function() { 94 | var ret = this.syncRemaining() 95 | if (ret === null) return null 96 | 97 | var sr = this.statsRemaining() 98 | 99 | // 15 seconds for stats by default 100 | if (ret && sr === null) sr = 15000 101 | 102 | if (ret === 0 && sr === null) return null 103 | 104 | return ret + sr 105 | } 106 | /** 107 | * Approximate remaining time for computing statistics 108 | */ 109 | User.prototype.statsRemaining = function() { 110 | var user = this 111 | 112 | var total = user.book_n 113 | 114 | if (total === null) return null 115 | 116 | if (user.isIng()) { 117 | // At least five seconds 118 | return Math.round(Math.sqrt(total) * (100 - (user.stats_p || 0)) * 5) + 5000 119 | } 120 | 121 | return null 122 | } 123 | /** 124 | * expected remaing time for finish collectng job 125 | */ 126 | User.prototype.syncRemaining = function() { 127 | var user = this 128 | var total = user.book_n 129 | var synced = user.book_synced_n 130 | 131 | if (!total) return null 132 | 133 | var perpage = task.API_REQ_PERPAGE 134 | 135 | var n = this.queue_length() 136 | 137 | return (task.API_REQ_DELAY + DB_RESPOND_DELAY) * n * Math.ceil((total - synced) / perpage) 138 | } 139 | 140 | User.prototype.isSyncing = function() { 141 | return this.last_synced_status === 'ing' 142 | } 143 | 144 | /** 145 | * is syncing timeout 146 | */ 147 | User.prototype.syncTimeout = function() { 148 | var remaining = this.remaining() 149 | // 30 minutes by default 150 | if (remaining === null || this.last_synced === null) return false 151 | 152 | return new Date() - this.last_synced > remaining + 300000 153 | } 154 | User.prototype.syncFailed = function() { 155 | return this.last_synced_status !== 'ing' || this.last_synced_status !== 'succeed' 156 | } 157 | 158 | /** 159 | * syncing timeout or failed 160 | */ 161 | User.prototype.needResync = function() { 162 | var user = this 163 | return !user.isIng() || (!user.stats && user.syncTimeout()) 164 | } 165 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doubanj", 3 | "version": "0.1.9", 4 | "homepage": "http:/doubanj.yjc.me", 5 | "description": "Data visualization for douban.com", 6 | "private": true, 7 | "contributors": [ 8 | "ktmud (http://ktmud.com)" 9 | ], 10 | "author": "ktmud (http://ktmud.com)", 11 | "dependencies": { 12 | "async": "~0.2.7", 13 | "batch-stream2": "~0.1.3", 14 | "body-parser": "^1.18.3", 15 | "bower": "~1.8.8", 16 | "cacheable": "~0.2.4", 17 | "connect-redis": "~3.4.0", 18 | "cookie-parser": "^1.4.3", 19 | "csurf": "^1.9.0", 20 | "debug": "^4.1.1", 21 | "express": "^4.16.4", 22 | "express-session": "^1.15.6", 23 | "generic-pool": "^3.4.2", 24 | "generic-pool-decorator": "^1.0.1", 25 | "grunt": "^1.0.4", 26 | "grunt-cli": "^1.3.2", 27 | "grunt-contrib-clean": "~0.5.0", 28 | "grunt-contrib-cssmin": "^3.0.0", 29 | "grunt-contrib-stylus": "^1.2.0", 30 | "grunt-contrib-uglify": "^4.0.1", 31 | "grunt-contrib-watch": "^1.1.0", 32 | "grunt-hashmap": "~0.1.3", 33 | "grunt-includes": "~0.4.2", 34 | "istatic": "^0.3.0", 35 | "jade": "~0.35.0", 36 | "lodash": "^4.17.15", 37 | "method-override": "^3.0.0", 38 | "moment": "^2.23.0", 39 | "mongodb": "~2.2.11", 40 | "oauth": "^0.9.14", 41 | "passport": "~0.1.15", 42 | "passport-douban": "~0.0.1", 43 | "passport-local": "~0.1.6", 44 | "raven": "~0.5.0", 45 | "redis": "~2.8.0", 46 | "redis-cached": "~0.0.1", 47 | "request": "^2.40.0", 48 | "resumable": "~0.0.1-1", 49 | "satelize": "^0.2.0", 50 | "timezone": "^1.0.6" 51 | }, 52 | "devDependencies": { 53 | "colors": "*", 54 | "mocha": "*" 55 | }, 56 | "engines": { 57 | "node": "11.4.0" 58 | }, 59 | "license": "MIT", 60 | "subdomain": "doubanj", 61 | "scripts": { 62 | "prepare": "npm prune", 63 | "postinstall": "./node_modules/bower/bin/bower --interactive=false --allow-root install && ./node_modules/grunt-cli/bin/grunt build --force", 64 | "test": "make test", 65 | "start": "node app.js" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /serve/admin/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktmud/doubanj/f7b5cc08fcb878ec655def7df7c7ff20244cdce9/serve/admin/index.js -------------------------------------------------------------------------------- /serve/api/index.js: -------------------------------------------------------------------------------- 1 | var error = require('debug')('dbj:api') 2 | var tasks = require('../../tasks') 3 | var cached = require('../../lib/cached') 4 | 5 | module.exports = function(app, central) { 6 | var utils = central.utils 7 | var cwd = central.cwd 8 | var User = require(cwd + '/models/user').User 9 | 10 | app.use('/api/', function(err, req, res, next) { 11 | if (err == 404) { 12 | res.statusCode = 404 13 | return res.json({ r: 404 }) 14 | } 15 | 16 | if (err) { 17 | error(err) 18 | if (err.stack) { 19 | console.error(err.stack) 20 | } 21 | 22 | central.raven.express(err, req, res) 23 | 24 | res.statusCode = err.statusCode || 500 25 | return res.json({ 26 | err: err.code || 500, 27 | msg: err.msg 28 | }) 29 | } 30 | next() 31 | }) 32 | app.get('/api/people/:uid/progress', function(req, res, next) { 33 | var people = res.data.people 34 | if (people) { 35 | var remaining = people.remaining() 36 | var delay = central.task.API_REQ_DELAY 37 | var ret = { 38 | r: 0, 39 | interval: people.progressInterval(remaining, delay), 40 | is_ing: people.isIng(), 41 | queue_length: tasks.getQueueLength('interest'), 42 | book_n: people.book_n, 43 | book_synced_n: people.book_synced_n, 44 | //last_synced: people.last_synced, 45 | //last_synced_status: people.last_synced_status, 46 | stats_status: people.stats_status, 47 | percents: people.progresses(), 48 | remaining: people.remaining() 49 | } 50 | //console.log(ret) 51 | res.json(ret) 52 | } else { 53 | res.statusCode = 404 54 | res.json({ 55 | r: 404 56 | }) 57 | } 58 | }) 59 | app.get('/api/people/:uid/', function(req, res, next) { 60 | var people = res.data.people 61 | if (people) { 62 | res.json(people) 63 | } else { 64 | res.statusCode = 404 65 | res.json({ 66 | r: 404 67 | }) 68 | } 69 | }) 70 | app.get('/api/latest_synced', function(req, res, next) { 71 | User.latestSynced(function(err, users) { 72 | var people = users && users.map(function(item) { 73 | return { 74 | url: item.url(), 75 | name: item.name 76 | } 77 | }) || [] 78 | 79 | people = utils.shuffle(people).slice(0,12) 80 | 81 | res.json({ 82 | r: err ? 500 : 0, 83 | people: people, 84 | }) 85 | }) 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /serve/auth/index.js: -------------------------------------------------------------------------------- 1 | var passport = require(central.cwd + '/lib/passport'); 2 | 3 | var utils = require('./utils'); 4 | 5 | module.exports = function(app, central) { 6 | app.get('/login', function(req, res, next) { 7 | req.session.redir = req.query.redir; 8 | res.render('auth/login', { fields: req.query }); 9 | }); 10 | 11 | app.post('/login', utils.authenticate, function(req, res, next) { 12 | res.render('auth/login', { 13 | fields: req.body 14 | }); 15 | }); 16 | 17 | app.get('/auth/douban', passport.authenticate('douban')); 18 | 19 | app.get('/auth/douban/callback', 20 | passport.authenticate('douban', { failureRedirect: '/login' }), 21 | function(req, res, next) { 22 | res.redirect(req.session.redir || '/mine'); 23 | }); 24 | 25 | app.get('/logout', function(req, res, next) { 26 | delete req.session.passport; 27 | delete req.session.user_id; 28 | var refer = req.get('Referer'); 29 | return res.redirect(refer || '/login'); 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /serve/auth/utils.js: -------------------------------------------------------------------------------- 1 | var passport = require(central.cwd + '/lib/passport'); 2 | 3 | var admin_users = central.conf.admin_users; 4 | 5 | module.exports = { 6 | authenticate: function(req, res, next) { 7 | passport.authenticate('local', function(err, user, info) { 8 | req.auth_error = err; 9 | req.user = user; 10 | return next(); 11 | })(req, res, next); 12 | }, 13 | require_admin: function(req, res, next) { 14 | if (!req.user) return next(404); 15 | if (admin_users.indexOf(req.user.uid) === -1) return next(404); 16 | return next(); 17 | }, 18 | require_login: function(req, res, next) { 19 | if (!req.user) { 20 | return res.redirect('/login?redir=' + encodeURIComponent(req.url)); 21 | } 22 | next(); 23 | }, 24 | require_login_json: function(req, res, next) { 25 | if (!req.user) { 26 | res.statusCode = 401; 27 | return res.json({ r: 401, msg: '登录已超时,请重新登录' }); 28 | } 29 | next(); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /serve/index.js: -------------------------------------------------------------------------------- 1 | var utils = require('./utils'); 2 | 3 | module.exports = function(app, central) { 4 | 5 | var User = require(central.cwd + '/models/user'); 6 | 7 | app.use(utils.navbar); 8 | 9 | app.get('/', function(req, res, next) { 10 | var uid = req.query.q; 11 | if (uid) { 12 | uid = utils.url2uid(uid); 13 | if (!uid) return res.redirect('/'); 14 | 15 | req.uid = uid; 16 | 17 | if (req.user && req.user.uid != uid) { 18 | User.getFromMongo(uid, function(err, user) { 19 | // 已经有统计结果,直接到契合指数页面 20 | if (user && user.book_stats) { 21 | return res.redirect(req.user.url() + 'click/' + user.uid); 22 | } 23 | next(); 24 | }); 25 | return; 26 | } 27 | } 28 | next(); 29 | }, function(req, res, next) { 30 | if (req.uid) return res.redirect('/people/' + req.uid + '/'); 31 | res.render('index'); 32 | }); 33 | 34 | app.post('/', function(req, res, next) { 35 | var uid = utils.url2uid(req.body.uid); 36 | if (!uid) return res.redirect('/'); 37 | res.redirect('/people/' + uid + '/'); 38 | }); 39 | 40 | ['people', 'api', 'misc', 'top', 'mine', 'queue', 'auth', 'tag', 'monitor'].forEach(function(item) { 41 | require('./' + item)(app, central); 42 | }); 43 | 44 | app.use(utils.errorHandler({ dump: central.conf.debug })); 45 | app.use(utils.errorHandler.notFound); 46 | }; 47 | -------------------------------------------------------------------------------- /serve/mine/index.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app, central) { 2 | 3 | var utils = require('../utils'); 4 | var auth_utils = require('../auth/utils'); 5 | 6 | var KEY_CLICK_BOOK_SCORE = require('../../models/consts').KEY_CLICK_BOOK_SCORE; 7 | 8 | var lodash = require('lodash'); 9 | var cwd = central.cwd; 10 | var User = require(cwd + '/models/user').User; 11 | var Interest = require(cwd + '/models/interest'); 12 | var tasks = require(cwd + '/tasks'); 13 | 14 | function get_click_book_scores(req, res, next) { 15 | var c = res.data = res.data || {}; 16 | var user = c.user = req.user; 17 | 18 | user.data(KEY_CLICK_BOOK_SCORE, function(err, ret) { 19 | ret = ret || {}; 20 | c.click_book_scores = ret; 21 | next(); 22 | }); 23 | } 24 | 25 | app.get('/mine', auth_utils.require_login, 26 | function(req, res, next) { 27 | var user = req.user; 28 | if (!user.stats) { 29 | res.redirect(user.url()); 30 | } 31 | next(); 32 | }, 33 | get_click_book_scores, 34 | function(req, res, next) { 35 | var c = res.data; 36 | var book_scores = c.click_book_scores; 37 | 38 | var user_ids = Object.keys(book_scores).sort(function(a, b) { return book_scores[b] - book_scores[a] }); 39 | user_ids = user_ids.slice(0, 4); 40 | 41 | User.gets(user_ids, function(err, users) { 42 | 43 | c.top_click_users = users.filter(function(u) { 44 | if (!u) return false; 45 | 46 | u.book_score = book_scores[u.id]; 47 | 48 | return true; 49 | }); 50 | 51 | res.render('mine', c); 52 | }); 53 | }); 54 | 55 | var rj = auth_utils.require_login_json; 56 | 57 | app.get('/api/mine/followings', rj, function(req, res, next) { 58 | if (!req.user) { 59 | res.statusCode = 401; 60 | res.json({ r: 401 }); 61 | return; 62 | } 63 | req.query = req.query || {}; 64 | 65 | if (req.query.fresh) { 66 | req.user.clearFollowings(next); 67 | return; 68 | } 69 | return next(); 70 | }, 71 | get_click_book_scores, 72 | function(req, res, next) { 73 | req.user.listFollowings({ 74 | limit: req.query.limit || 24, 75 | start: req.query.start 76 | }, function(err, result) { 77 | if (err) { 78 | res.statusCode = err.code || 200; 79 | return res.json({ r: err.code || 500, msg: err.message }); 80 | } 81 | 82 | var all_scores = res.data.click_book_scores; 83 | 84 | result.forEach(function(item) { 85 | item.ready = (item.stats_p == 100); 86 | item.ing = item.last_synced_status === 'ing'; 87 | item.score = all_scores[item.id]; 88 | }); 89 | 90 | res.json({ 91 | r: 0, 92 | items: result 93 | }); 94 | }); 95 | }); 96 | 97 | }; 98 | -------------------------------------------------------------------------------- /serve/misc.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function(app, central) { 3 | var User = require(central.cwd + '/models/user').User; 4 | 5 | app.get('/about', function(req, res, next) { 6 | User.count(function(err, n) { 7 | res.render('misc/about', { 8 | n_people: n 9 | }); 10 | }) 11 | }); 12 | app.get('/about/click', function(req, res, next) { 13 | res.render('misc/about_click'); 14 | }); 15 | app.get('/about/privacy', function(req, res, next) { 16 | res.render('misc/about_privacy'); 17 | }); 18 | 19 | app.get('/donate', function(req, res, next) { 20 | res.render('misc/donate'); 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /serve/monitor/index.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app, central) { 2 | 3 | var auth_utils = require('../auth/utils') 4 | var tasks = require('../../tasks') 5 | var async = require('async') 6 | 7 | var mongo = central.mongo 8 | var ONE_HOUR = 60 * 60 * 1000 9 | 10 | app.get('/monitor', auth_utils.require_admin, function(req, res, next) { 11 | var c = { 12 | get_queue: tasks.getQueue 13 | } 14 | mongo(function(db) { 15 | var col_user = db.collection('user') 16 | async.parallel([ 17 | function(callback) { 18 | col_user.count(callback) 19 | }, 20 | function(callback) { 21 | col_user.count({ last_synced_status: 'succeed' }, callback) 22 | }, 23 | function(callback) { 24 | col_user.count({ 25 | last_synced_status: 'ing', 26 | }, callback) 27 | }, 28 | function(callback) { 29 | col_user.find({ 30 | last_synced_status: { $ne: 'succeed' }, 31 | last_synced: { $lt: new Date(new Date() - ONE_HOUR) }, 32 | }).toArray(callback) 33 | }, 34 | function(callback) { 35 | db.collection('book').count(callback) 36 | }, 37 | function(callback) { 38 | db.collection('book_interest').count(callback) 39 | } 40 | ], 41 | function(err, results) { 42 | c.err = err 43 | c.total = results[0] 44 | c.n_succeed = results[1] 45 | c.n_ing = results[2] 46 | c.timeouted = results[3] 47 | c.n_book = results[4] 48 | c.n_book_interest = results[5] 49 | res.render('monitor', c) 50 | }) 51 | }) 52 | }) 53 | 54 | 55 | } 56 | -------------------------------------------------------------------------------- /serve/oauth/index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function(app, central) { 3 | }); 4 | -------------------------------------------------------------------------------- /serve/queue.js: -------------------------------------------------------------------------------- 1 | var utils = require('./utils'); 2 | 3 | 4 | module.exports = function(app, central) { 5 | var tasks = require(central.cwd + '/tasks'); 6 | 7 | app.post('/queue', utils.getUser({ 8 | redir: '/', 9 | }), function(req, res, next) { 10 | var uid = res.data.uid; 11 | var user = res.data.people; 12 | 13 | if (!user) { 14 | res.redirect('/people/' + uid + '/'); 15 | return; 16 | } 17 | 18 | var uid = user.uid || user.id; 19 | 20 | var fresh = 'fresh' in req.body; 21 | 22 | tasks.interest.collect_book({ 23 | user: user, 24 | // to start a sync discard of running states 25 | force: 'force' in req.body, 26 | fresh: !user.desc || fresh, 27 | }); 28 | 29 | var fn = fresh ? 'reset' : 'markSync'; 30 | user[fn](function() { 31 | res.redirect('/people/' + uid + '/'); 32 | }); 33 | }); 34 | }; 35 | 36 | -------------------------------------------------------------------------------- /serve/tag/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * top users by tag 3 | */ 4 | module.exports = function(app, central) { 5 | 6 | var debug = require('debug'); 7 | var error = debug('dbj:serve:tag:error'); 8 | var async = require('async'); 9 | 10 | var Subject = require(central.cwd + '/models/subject'); 11 | var User = require(central.cwd + '/models/user'); 12 | var toplist = require(central.cwd + '/models/toplist'); 13 | 14 | app.param('tagname', function(req, res, next, tagname) { 15 | var c = res.data = res.data || {}; 16 | 17 | var query = req.query || {}; 18 | 19 | c.tagname = tagname; 20 | 21 | function get_ids(obj_name, start, limit) { 22 | 23 | start = start || 0; 24 | limit = limit || 18; 25 | 26 | return function(callback) { 27 | toplist.tops_by_tag(tagname, obj_name, { 28 | start: start, 29 | limit: limit 30 | }, function(err, results) { 31 | c[obj_name + 's'] = results; 32 | callback(err); 33 | }); 34 | } 35 | } 36 | 37 | async.parallel([ 38 | get_ids('book', query.book_start), 39 | get_ids('book_done_user', query.user_start, 32) 40 | ], next); 41 | }, function(req, res, next) { 42 | var c = res.data; 43 | 44 | function get_objects(context_val, cls) { 45 | return function(callback) { 46 | var ids = c[context_val]; 47 | 48 | if (!ids) { 49 | c[context_val] = null; 50 | return callback(); 51 | } 52 | 53 | cls.gets(ids, function(err, items) { 54 | items = items || []; 55 | items.forEach(function(item, i) { 56 | item._tag_count = ids[i].count; 57 | }); 58 | 59 | // attach to the render context 60 | c[context_val] = items; 61 | 62 | callback(err); 63 | }); 64 | } 65 | } 66 | 67 | async.parallel([ 68 | get_objects('books', Subject.book), 69 | get_objects('book_done_users', User), 70 | ], next); 71 | }); 72 | 73 | app.get('/tag/:tagname', function(req, res, next) { 74 | var c = res.data; 75 | 76 | c.referer = req.get('referer'); 77 | 78 | if (!c.books) return next(404); 79 | res.render('tag', c); 80 | }); 81 | 82 | 83 | }; 84 | -------------------------------------------------------------------------------- /serve/top/index.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app, central) { 2 | var toplist = require('../../models/toplist'); 3 | 4 | function attach(method, period) { 5 | var tmp = method.split('.'); 6 | var ns = tmp[0], fn = tmp[1]; 7 | 8 | return function(req, res, next) { 9 | // toplist.book.hardest_reader 10 | toplist[ns][fn](period, function(err, data) { 11 | if (err) { 12 | res.data.errors.push(err); 13 | } 14 | res.data[fn + '_' + period] = data; 15 | next(); 16 | }); 17 | } 18 | } 19 | 20 | app.get('/top/', function(req, res, next) { 21 | res.data = res.data || {}; 22 | res.data.errors = []; 23 | res.data.title = '排行榜 - ' + central.conf.site_name; 24 | next(); 25 | }, 26 | attach('book.hardest_reader', 'last_30_days'), 27 | attach('book.hardest_reader', 'last_12_month'), 28 | attach('book.hardest_reader', 'all_time'), 29 | function(req, res, next) { 30 | res.render('toplist/index', res.data); 31 | }); 32 | }; 33 | 34 | -------------------------------------------------------------------------------- /serve/utils/errorHandler.js: -------------------------------------------------------------------------------- 1 | var debug = require('debug'); 2 | var error = debug('dbj:error'); 3 | 4 | var raven = require(process.cwd() + '/lib/raven'); 5 | 6 | module.exports = function(options) { 7 | options = options || {}; 8 | 9 | // 500 / 403 page 10 | return function appError(err, req, res, next) { 11 | if (err == 403 || (String(err)).indexOf('Forbidden') != -1) { 12 | res.statusCode = 403; 13 | if (res.headerSent) return res.end(); 14 | res.render('403', { 15 | statusCode: 403, 16 | data: { r: 1, err: err } 17 | }); 18 | return; 19 | } 20 | 21 | if (err == 404 || err === 'not_found' || err.name == 'Not Found') { 22 | return notFound(req, res, next); 23 | } 24 | 25 | //error(err); 26 | error(err.toString()); 27 | 28 | if (typeof err === 'string') { 29 | res._exception = err; 30 | } else { 31 | res._exception = (err.name || err.reason); 32 | } 33 | 34 | req.session.onesalt = Date.now(); 35 | 36 | raven.express(err, req, res); 37 | 38 | if (res.headerSent) return res.end(); 39 | 40 | res.statusCode = 500; 41 | res.render('500', { 42 | onesalt: req.session.onesalt, 43 | statusCode: 500, 44 | data: { r: 1, err: err }, 45 | err: err, 46 | stack: options.dump && err.stack 47 | }); 48 | } 49 | }; 50 | 51 | var notFound = module.exports.notFound = function(req, res, next) { 52 | if (res.headerSent) return res.end(); 53 | if (req.method == 'HEAD') { 54 | return next(); 55 | } 56 | res.statusCode = 404; 57 | res.render('404', { 58 | statusCode: 404, 59 | data: { r: 1, err: 404 } 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /serve/utils/index.js: -------------------------------------------------------------------------------- 1 | var cwd = central.cwd; 2 | var User = require(cwd + '/models/user').User; 3 | 4 | var reg_uid = /\/people\/([^\/]+)/; 5 | 6 | function url2uid(url) { 7 | url = url || ''; 8 | url = url.trim(); 9 | var m = url.match(reg_uid); 10 | if (m) uid = m[1]; 11 | return m && m[1] || url; 12 | 13 | } 14 | function matchPeople(req) { 15 | var uid = req.body.uid || req.query.uid || req.params.uid; 16 | return url2uid(uid); 17 | } 18 | 19 | function getUser(opts) { 20 | opts = opts || {}; 21 | var redir = opts.redir; 22 | var fn = opts.fn || matchPeople; 23 | return function(req, res, next) { 24 | var uid = fn(req); 25 | var c = res.data = { 26 | qs: req.query, 27 | title: uid + '的豆瓣酱', 28 | uid: uid 29 | }; 30 | 31 | if (!uid) { 32 | if (redir) { 33 | if (typeof redir === 'function') redir = redir(req); 34 | return res.redirect(redir); 35 | } 36 | return next(404); 37 | } 38 | 39 | function end(err, people) { 40 | c.err = err; 41 | c.people = people; 42 | if (people) c.title = people.name + '的豆瓣酱'; 43 | next(); 44 | } 45 | 46 | if (req.user && req.user.uid === uid) { 47 | return end(null, req.user); 48 | } 49 | 50 | User.get(uid, end); 51 | }; 52 | } 53 | 54 | module.exports = { 55 | errorHandler: require('./errorHandler'), 56 | url2uid: url2uid, 57 | getUser: getUser, 58 | navbar: function navbar(req, res, next) { 59 | var links = []; 60 | links.push({ 61 | href: '/top/', 62 | active: req.url === '/top/', 63 | text: '榜单' 64 | }); 65 | links.unshift({ 66 | href: central.conf.site_root, 67 | active: req.url === '/', 68 | text: '首页' 69 | }); 70 | links.push({ 71 | href: '/mine', 72 | active: req.url === '/mine', 73 | text: '我的' 74 | }); 75 | res.locals.navbar_links = links; 76 | res.locals.user = req.user; 77 | next(); 78 | } 79 | }; 80 | 81 | -------------------------------------------------------------------------------- /static/css/base.styl: -------------------------------------------------------------------------------- 1 | @import 'base/reset' 2 | @import 'base/layout' 3 | 4 | @import "widgets/rbox" 5 | 6 | @import 'interests/list' 7 | 8 | 9 | @import 'home' 10 | @import 'chart/basic' 11 | @import 'chart/details' 12 | @import 'chart/book' 13 | 14 | @import 'toplist/basic' 15 | -------------------------------------------------------------------------------- /static/css/base/feel.styl: -------------------------------------------------------------------------------- 1 | @import 'nib'; 2 | 3 | sans = STHeiti,"Helvetica Neue",Helvetica,Arial,sans-serif 4 | h1_size = 1.7em 5 | h2_size = 1.35em 6 | h3_size = 1.2em 7 | h4_size = 1.1em 8 | desc_size= 0.85em 9 | 10 | p.small 11 | font-size: 0.85em; 12 | 13 | .dot 14 | display: inline-block; 15 | width: 5px; 16 | height: 5px; 17 | overflow: hidden; 18 | background: #ccc; 19 | border: 1px solid #fff; 20 | box-sizing(content-box); 21 | border-radius(3px); 22 | 23 | .dot-red 24 | background: #f56; 25 | 26 | .dot-yellow 27 | background: #fc0; 28 | 29 | .dot-green 30 | background: #1b0; 31 | 32 | .hr-or 33 | border-top: 1px solid #eee; 34 | text-align: center; 35 | margin: 40px 0; 36 | height: 0; 37 | overflow: visible; 38 | color: #666; 39 | span 40 | position: relative; 41 | padding: 0 1em; 42 | line-height: 1; 43 | background: #fff; 44 | top: -1.5em; 45 | -------------------------------------------------------------------------------- /static/css/base/layout.styl: -------------------------------------------------------------------------------- 1 | .vmiddle 2 | margin: 10% auto 3 | padding: 20px 4 | max-width: 600px 5 | text-align: center 6 | 7 | pre 8 | text-align: left 9 | 10 | h1, h2, h3, h4 11 | font-family: sans 12 | margin-bottom: 0.5em 13 | line-height: 1.5 14 | 15 | h1 16 | font-size: h1_size 17 | text-align: center 18 | 19 | h2 20 | font-size: h2_size 21 | .btn 22 | margin: 0 0.5em 23 | vertical-align: text-top 24 | h3 25 | font-size: h3_size 26 | 27 | h4 28 | font-size: h4_size 29 | 30 | #footer 31 | border-top: 1px solid #e0e0e0 32 | padding: 20px 33 | text-align: right 34 | font-size: 0.8em 35 | a 36 | color: #666 37 | margin-left: 10px 38 | &:hover 39 | color: #333 40 | 41 | #main 42 | position: relative 43 | padding: 30px 0 44 | padding-bottom: 20px 45 | 46 | h1 47 | margin-top: 0 48 | 49 | .desc 50 | line-height: 1.4em 51 | font-size: desc_size 52 | word-wrap: break-word 53 | 54 | .people-intro 55 | .pull-right 56 | display: inline-block 57 | margin: 0 0 0.5em 0.5em 58 | 59 | .alert 60 | p 61 | margin: 0.5em 0 62 | .progress 63 | margin-bottom: 0 64 | 65 | .label-compact 66 | font-weight: 200 67 | padding: 0 0.2em 68 | line-height: 1.4em 69 | 70 | .mod 71 | margin-bottom: 2em 72 | 73 | .tagcloud 74 | margin-bottom: 1em 75 | 76 | .tag-item 77 | display: inline-block 78 | margin-right: 0.7em 79 | a 80 | margin-right: 0 0.1em 81 | .muted 82 | font-size: 0.9em 83 | 84 | .list-small 85 | font-size: 12px 86 | margin-left: 1.5em 87 | 88 | .breadcrumb 89 | margin: 20px 0 90 | 91 | a.anchor 92 | display: inline-block 93 | zoom: 1 94 | vertical-align: middle 95 | color: #aaa 96 | margin-left: 10px 97 | font-size: 16px 98 | line-height: 1em 99 | padding: 2px 5px 100 | font-family: arial 101 | 102 | &:hover 103 | text-decoration: none 104 | background: #aa 105 | color: #fff 106 | 107 | .people-info 108 | img 109 | width: 48px 110 | height: 48px 111 | 112 | .notice-more 113 | margin-top: 8em 114 | 115 | .pager-center 116 | text-align: center 117 | margin: 20px 0 118 | .pagination 119 | margin: 0 10px 120 | vertical-align: middle 121 | 122 | @media (max-width: 768px) 123 | .navbar:before, .navbar:after 124 | content: "" 125 | display: none 126 | .row 127 | margin: 0 128 | .mod, .chart 129 | padding: 0 130 | @media (min-width: 440px) and (max-width: 768px) 131 | #tops .span3 132 | float: left 133 | width: 50% 134 | -------------------------------------------------------------------------------- /static/css/base/reset.styl: -------------------------------------------------------------------------------- 1 | @import 'nib' 2 | 3 | a 4 | color: #37a; 5 | 6 | input[type="text"] 7 | border-radius(2px) 8 | 9 | ol, ul 10 | padding: 0 11 | 12 | .alert, .btn 13 | border-radius(2px) 14 | 15 | .navbar 16 | background: #f2f7f6 17 | 18 | input[type="text"] 19 | box-shadow(none) 20 | 21 | blockquote 22 | p 23 | font-size: 1.15em; 24 | line-height: 1.4em; 25 | margin: 0.2em 0 0.6em; 26 | 27 | .btn 28 | .glyphicon 29 | vertical-align: middle; 30 | line-height: 1; 31 | 32 | .nav-pills-small 33 | > li 34 | margin: 0 2px; 35 | > li > a 36 | font-size: 12px; 37 | border-radius(2px) 38 | padding: 0 0.4em; 39 | line-height: 1.5em; 40 | 41 | .jiathis_style .searchTxt 42 | min-height: 1em; 43 | padding: 0; 44 | line-height: 1em; 45 | box-shadow(none); 46 | 47 | .navbar 48 | background: none; 49 | color: #999; 50 | border: 0; 51 | 52 | .navbar-default 53 | border-bottom: none; 54 | 55 | .navbar .nav a 56 | color: #444; 57 | padding: 25px 14px 8px; 58 | border: 1px solid #fff; 59 | margin: 0 8px; 60 | 61 | .navbar-default .navbar-nav > li > a:hover, .navbar-defaultult .navbar-nav > li > a:focus 62 | color: #777; 63 | 64 | .navbar-default .navbar-nav > li > a 65 | color: #444; 66 | 67 | .navbar .nav > .active > a, .navbar .nav > .active > a:hover, .navbar .nav > .active > a:focus 68 | background: #effbf0; 69 | border-width: 0 1px 1px; 70 | border-color: #cae0ca; 71 | padding-top: 28px; 72 | padding-bottom: 10px; 73 | color: #1b8e38; 74 | 75 | .navbar-default .navbar-brand 76 | padding-top: 25px; 77 | color: #444; 78 | font-weight: 500; 79 | 80 | .navbar-form 81 | padding-top: 10px; 82 | 83 | h2 small 84 | font-size: 70%; 85 | -------------------------------------------------------------------------------- /static/css/bootstrap.css: -------------------------------------------------------------------------------- 1 | @import '../../bower_components/bootstrap/dist/css/bootstrap.css' 2 | @import './jiathis_share.css' 3 | -------------------------------------------------------------------------------- /static/css/chart/basic.styl: -------------------------------------------------------------------------------- 1 | text 2 | font-size: 11px; 3 | 4 | .chart 5 | padding: 10px; 6 | margin-bottom: 20px; 7 | position: relative; 8 | 9 | .caption 10 | text-align: center; 11 | 12 | .chart-pie 13 | height: 160px; // default height 14 | 15 | .chart-bar 16 | height: 260px; 17 | .legend 18 | fill: #777; 19 | 20 | .chart-treemap 21 | height: 380px; 22 | position: relative; 23 | 24 | .node 25 | border: 1px dotted #fff; 26 | position: absolute; 27 | font-size: 0.8em; 28 | line-height: 1.2; 29 | z-index: 1; 30 | 31 | transition(0.4s all); 32 | 33 | &:hover 34 | box-shadow(0 3px 7px rgba(0,0,0,0.35)); 35 | border-style: solid; 36 | z-index: 10; 37 | 38 | .parent 39 | border-style: solid; 40 | .parent:hover 41 | z-index: 1; 42 | 43 | a 44 | display: block; 45 | height: 88%; 46 | padding: 4% 6%; 47 | margin: 1%; 48 | color: #444; 49 | color: rgba(0,0,0,0.9); 50 | overflow: hidden; 51 | &:hover 52 | text-shadow: 0 1px 0 rgba(255,255,255,0.6); 53 | text-decoration: none; 54 | color: #000; 55 | 56 | .arc path 57 | stroke: #fff; 58 | 59 | .axis path, 60 | .axis line 61 | fill: none; 62 | stroke: #bbb; 63 | shape-rendering: crispEdges; 64 | 65 | .bar-style-toggler 66 | line-height: 1; 67 | label 68 | vertical-align: middle; 69 | margin-left: 1em; 70 | input 71 | margin-right: 3px; 72 | 73 | .chart-filter 74 | position: absolute; 75 | left: 35%; 76 | background: rgba(255,255,255,0.4); 77 | -------------------------------------------------------------------------------- /static/css/chart/book.styl: -------------------------------------------------------------------------------- 1 | #d-tags 2 | height: 200px; 3 | -------------------------------------------------------------------------------- /static/css/chart/details.styl: -------------------------------------------------------------------------------- 1 | .details 2 | margin-left: 40px; 3 | 4 | .details-content 5 | display: none; 6 | 7 | .details-toggler 8 | margin: -40px 0 20px 0; 9 | 10 | .details-expanded 11 | .details-content 12 | display: block; 13 | -------------------------------------------------------------------------------- /static/css/home.styl: -------------------------------------------------------------------------------- 1 | #start-form 2 | line-height: 40px; 3 | max-width: 700px; 4 | margin: 8% auto 12%; 5 | 6 | @import './widgets/ticker' 7 | -------------------------------------------------------------------------------- /static/css/interests/list.styl: -------------------------------------------------------------------------------- 1 | .label-status 2 | padding: 0.1em 0.3em 3 | font-weight: 100 4 | 5 | .stars 6 | color: #e41 7 | font-size: 1.1em 8 | letter-spacing: 0.07em 9 | margin: 0 0.2em 10 | 11 | ol.comments-list 12 | list-style: decimal inside 13 | 14 | .comments-list 15 | margin: 0 16 | list-style: square inside 17 | 18 | .subject-image 19 | float: right 20 | margin: 1em 0 5px 1em 21 | 22 | li 23 | padding: 30px 10px 24 | color: #aaa 25 | border-top: 1px dotted #ddd 26 | overflow: hidden 27 | zoom: 1 28 | font-size: 16px 29 | 30 | p, blockquote 31 | color: #111 32 | 33 | blockquote 34 | border-left: 0 35 | margin: 1em 1em 36 | padding-left: 1em 37 | font-size: inherit 38 | 39 | &:before, &:after 40 | content: '\201C' 41 | color: #eaeaea 42 | font-size: 3em 43 | line-height: 0 44 | vertical-align: bottom 45 | font-weight: bold 46 | font-family: Arial, Helvetica, sans-serif 47 | &:before 48 | margin-left: -.5em 49 | &:after 50 | content: '' 51 | float: right 52 | margin-right: -0.3em 53 | vertical-align: bottom 54 | 55 | th 56 | text-align: center 57 | font-weight: 400 58 | .heading th 59 | font-size: 1.3em 60 | padding: 10px 6px 61 | th.subject 62 | img 63 | display: block 64 | width: 80px 65 | margin: 8px auto 66 | width: 100px 67 | min-width: 60px 68 | font-size: 12px 69 | td 70 | width: 45% 71 | 72 | table.comments-list 73 | margin-bottom: 30px 74 | blockquote 75 | &:before, &:after 76 | font-size: 2.5em 77 | color: #ddd 78 | &:after 79 | content: '\201d' 80 | p 81 | font-size: 1em 82 | -------------------------------------------------------------------------------- /static/css/mine.styl: -------------------------------------------------------------------------------- 1 | @import 'nib' 2 | 3 | @import './widgets/avatar_list.styl' 4 | 5 | .users-avatar 6 | .prop-book_score 7 | font-size: 14px 8 | font-weight: 600; 9 | color: #888; 10 | 11 | .logins 12 | margin: 5% 5% 10%; 13 | h2 14 | text-align: center; 15 | margin: 20px; 16 | 17 | .douban-login 18 | text-align: center; 19 | border-right: dashed 1px #ddd; 20 | padding: 30px 30px 80px; 21 | 22 | .mod 23 | h2 24 | border-bottom: 1px solid #ddd; 25 | margin-top: 0; 26 | margin-bottom: 20px; 27 | 28 | .aside 29 | .mod 30 | padding: 10px 20px; 31 | background: #f9f6f3; 32 | border-radius: 4px; 33 | h2 34 | border-bottom-color: #d9d9d9; 35 | .users-avatar 36 | margin-top: -10px; 37 | .users-avatar li 38 | height: 110px; 39 | a:hover 40 | background: #efe6df; 41 | 42 | .friends 43 | ul 44 | margin: 20px 0; 45 | li 46 | position: relative; 47 | .btn-block 48 | font-weight: 100; 49 | background: #e6e6e6; 50 | border: 0; 51 | color: #666; 52 | margin: 40px; 53 | width: auto; 54 | .disabled:hover 55 | background: #e9e9e9; 56 | 57 | .lnk-logout 58 | position: absolute; 59 | right: 20px; 60 | top: 10px; 61 | 62 | @import './mine/click.styl' 63 | -------------------------------------------------------------------------------- /static/css/mine/click.styl: -------------------------------------------------------------------------------- 1 | #aside-click 2 | text-align: center; 3 | margin-bottom: 30px; 4 | 5 | .click-number 6 | font-size: 3.2em; 7 | line-height: 100%; 8 | margin: 10px 0 20px; 9 | 10 | table.click-indexes 11 | width: 100%; 12 | text-align: center; 13 | margin-bottom: 30px; 14 | .click-number 15 | margin-bottom: 0; 16 | td 17 | vertical-align: middle; 18 | width: 50%; 19 | 20 | td.click-grade 21 | font-size: 2em; 22 | line-height: 1.6; 23 | padding: 15px; 24 | border-radius: 2px; 25 | text-align: center; 26 | background: #fff; 27 | border-radius: 5px; 28 | letter-spacing: 0.1em; 29 | 30 | p.reliability 31 | margin: 20px 0 0; 32 | text-align: center; 33 | font-size: 12px; 34 | color: #999; 35 | 36 | .rbox-list a 37 | border-radius: 3px 3px 0 0; 38 | 39 | .tagcloud 40 | .tag-item 41 | display: inline-block; 42 | padding: 3px 8px; 43 | margin: 4px; 44 | background: #f6f6f6; 45 | color: #555; 46 | font-size: 13px; 47 | border: 1px solid #d9d9d9; 48 | border-radius: 2px; 49 | strong 50 | font-weight: 400; 51 | &:hover 52 | text-decoration: none; 53 | background: #fafafa; 54 | -------------------------------------------------------------------------------- /static/css/tag.styl: -------------------------------------------------------------------------------- 1 | @import 'nib' 2 | 3 | .rbox-supple 4 | text-align: right; 5 | padding-top: 5px; 6 | padding-bottom: 5px; 7 | border-color: #f98; 8 | strong 9 | font-size: 14px; 10 | 11 | .interest-rboxes a:hover 12 | border-radius: 3px 3px 0 0; 13 | .rbox-supple 14 | background: #fee8e9; 15 | margin-top: 8px; 16 | border-top-color: #ecc; 17 | 18 | -------------------------------------------------------------------------------- /static/css/toplist/basic.styl: -------------------------------------------------------------------------------- 1 | .top-n 2 | position: absolute; 3 | display: inline-block; 4 | padding: 0 0.4em; 5 | line-height: 1.4em; 6 | font-family: 'Times New Rome', serif; 7 | background: #dfdfdf; 8 | font-size: 11px; 9 | border-radius: 2px; 10 | color: #fff; 11 | left: -5px; 12 | top: -6px; 13 | text-align: center; 14 | -------------------------------------------------------------------------------- /static/css/widgets/avatar_list.styl: -------------------------------------------------------------------------------- 1 | .users-avatar 2 | padding: 0; 3 | margin: 0 0 20px; 4 | text-align: center; 5 | font-size: 12px; 6 | li 7 | list-style: none; 8 | float: left; 9 | margin: 5px 5px; 10 | width: 96px; 11 | height: 84px; 12 | .dot 13 | position: absolute; 14 | top: 9px; 15 | right: 22%; 16 | a 17 | display: block; 18 | padding: 12px 0 4px; 19 | &:hover 20 | text-decoration: none; 21 | background: #f0f6f9; 22 | img 23 | margin-bottom: 2px; 24 | border-radius: 2px; 25 | 26 | @media (max-width: 460px) 27 | .users-avatar li 28 | width: 84px; 29 | margin: 5px 3px; 30 | -------------------------------------------------------------------------------- /static/css/widgets/rbox.styl: -------------------------------------------------------------------------------- 1 | .rbox-list 2 | margin 0 3 | a 4 | display: block; 5 | padding: 8px 6 | border 1px solid #e0e0e0 7 | color: #333; 8 | font-size: 12px; 9 | border-radius(3px) 10 | 11 | &:hover 12 | text-decoration: none; 13 | background: #fffdfa; 14 | border-color: #f98; 15 | .title 16 | color: #45b; 17 | font-size: 14px; 18 | margin: 0.8em 0 0; 19 | .rbox-supple 20 | border-top: 1px solid #f98; 21 | 22 | p 23 | margin: 0.5em 0 0.3em; 24 | line-height: 1.3em 25 | 26 | .pic 27 | margin: 0.6em auto; 28 | text-align: center; 29 | img 30 | border: 1px solid #eee; 31 | padding: 3px; 32 | border-radius(2px) 33 | 34 | li 35 | position: relative; 36 | vertical-align: top; 37 | display inline-block 38 | margin 10px 8px 30px 39 | 40 | .rbox-supple 41 | border: 1px solid #ddd; 42 | border-top: 0; 43 | background: #fafafa; 44 | padding: 10px; 45 | font-size: 12px; 46 | position: absolute; 47 | right: 0; 48 | display: none; 49 | width: 106px; 50 | z-index: 1; 51 | border-radius(0 0 3px 3px); 52 | box-sizing(content-box); 53 | 54 | li:hover .rbox-supple 55 | display: block; 56 | 57 | .mixed-stars 58 | text-align: center 59 | .splitter 60 | color: #999; 61 | 62 | .interest-rboxes 63 | img 64 | width: 100px; 65 | .pic, p 66 | width: 110px; 67 | 68 | .people-rboxes 69 | li 70 | text-align: center; 71 | img 72 | height: 48px; 73 | width: 48px; 74 | 75 | p.title 76 | font-size: 13px; 77 | p 78 | width: 80px; 79 | height: 1.3em; 80 | overflow: hidden; 81 | 82 | p.tags 83 | font-size: 12px; 84 | font-weight: 100; 85 | color: #b3b3b3; 86 | height: 2.4em; 87 | text-align: left; 88 | 89 | .rbox-supple 90 | width: 70px; 91 | 92 | a 93 | padding: 3px 5px 7px; 94 | .rbox-supple 95 | margin-left: -6px; 96 | margin-top: 7px; 97 | 98 | 99 | a:hover 100 | background: #fafdff; 101 | border-color: #90d0ec; 102 | .rbox-supple 103 | border-color: #90d0ec; 104 | border-top-color: #d9e3ef; 105 | background: #fff; 106 | 107 | 108 | @media (max-width: 768px) 109 | .rbox-list 110 | margin: 0 -8px; 111 | -------------------------------------------------------------------------------- /static/css/widgets/ticker.styl: -------------------------------------------------------------------------------- 1 | 2 | .ticker 3 | height: 20px; 4 | 5 | h4 6 | text-align: right; 7 | margin: 0; 8 | font-size: 14px; 9 | 10 | .ticker-content 11 | line-height: 1em; 12 | height: 20px; 13 | float: left; 14 | overflow: hidden; 15 | 16 | ul, p 17 | padding: 0; 18 | margin: 0; 19 | 20 | li 21 | margin: 0; 22 | display: inline; 23 | list-style: none; 24 | 25 | a 26 | color: #999; 27 | display: inline-block; 28 | margin-right: 0.7em; 29 | &:hover 30 | color: #333; 31 | text-decoration: none; 32 | border-bottom: 1px dotted #ccc; 33 | -------------------------------------------------------------------------------- /static/dist/bdsitemap.txt: -------------------------------------------------------------------------------- 1 | PjuQVjv1o1oq1GCT -------------------------------------------------------------------------------- /static/dist/favicon.ico: -------------------------------------------------------------------------------- 1 | ../favicon.ico -------------------------------------------------------------------------------- /static/dist/fonts: -------------------------------------------------------------------------------- 1 | ../fonts -------------------------------------------------------------------------------- /static/dist/jiathis_utility.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | JiaThis Utility Frame 5 | 6 | 7 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /static/dist/pics: -------------------------------------------------------------------------------- 1 | ../pics -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktmud/doubanj/f7b5cc08fcb878ec655def7df7c7ff20244cdce9/static/favicon.ico -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- 1 | ../../bower_components/bootstrap/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.svg: -------------------------------------------------------------------------------- 1 | ../../bower_components/bootstrap/fonts/glyphicons-halflings-regular.svg -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- 1 | ../../bower_components/bootstrap/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- 1 | ../../bower_components/bootstrap/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /static/js/chart/all.js: -------------------------------------------------------------------------------- 1 | require.register("chart/all", function(exports, require, module){ 2 | var lodash = require('/lodash')._; 3 | var d3 = require('/d3'); 4 | 5 | // @import ./pie.js 6 | // @import ./bar.js 7 | // @import ./treemap.js 8 | 9 | module.exports = { 10 | Pie: Pie, 11 | Treemap: Treemap, 12 | Bar: Bar, 13 | }; 14 | }); 15 | -------------------------------------------------------------------------------- /static/js/chart/consts.js: -------------------------------------------------------------------------------- 1 | //var DEFAULT_COLORS = ["#98abc5", "#8a89a6", "#7b6888", "#6b486b", "#a05d56", "#d0743c", "#ff8c00"]; 2 | //var DEFAULT_COLORS = ["", "", "", "", ""]; 3 | //var DEFAULT_COLORS = ["#d9ceb2", "#948c75", "#d5ded9", "#7a6a53", "#99b2b7"]; 4 | var DEFAULT_COLORS = ["#009ecf", "#ff7050", "#90e939", "#63dae0", "#ffa03f", "#8fbe00", "#78c0a8","#ed303c"]; 5 | //var DEFAULT_COLORS = ["#00a8c6", "#40c0cb", "#f9f2e7", "#aee239", "#8fbe00"]; 6 | //var DEFAULT_COLORS = ["#c02942", "#542437", "#ecd078", "#d95b43", "#53777a"]; 7 | //var DEFAULT_COLORS = ["#a8dba8", "#79bd9a",]; 8 | //var DEFAULT_COLORS = ["#d1f2a5", "#effab4", "#ffc48c", "#ff9f80", "#f56991"]; 9 | 10 | function d3_colors(list) { 11 | //return d3.scale.category10(); 12 | return d3.scale.ordinal().range(list); 13 | } 14 | 15 | function timeFormat(formats) { 16 | return function(date) { 17 | var i = formats.length - 1, f = formats[i]; 18 | while (!f[1](date)) f = formats[--i]; 19 | return f[0](date); 20 | }; 21 | } 22 | 23 | var customTimeFormat = timeFormat([ 24 | [d3.time.format("%Y"), function() { return true; }], 25 | [d3.time.format("%B"), function(d) { return d.getMonth(); }], 26 | [d3.time.format("%b %d"), function(d) { return d.getDate() != 1; }], 27 | [d3.time.format("%a %d"), function(d) { return d.getDay() && d.getDate() != 1; }], 28 | [d3.time.format("%I %p"), function(d) { return d.getHours(); }], 29 | [d3.time.format("%I:%M"), function(d) { return d.getMinutes(); }], 30 | [d3.time.format(":%S"), function(d) { return d.getSeconds(); }], 31 | [d3.time.format(".%L"), function(d) { return d.getMilliseconds(); }] 32 | ]); 33 | 34 | -------------------------------------------------------------------------------- /static/js/chart/pie.js: -------------------------------------------------------------------------------- 1 | // @import ./consts.js 2 | 3 | function Pie(container, options) { 4 | if (!container) throw new Error('Must give a container for Pie chart.'); 5 | if (!(this instanceof Pie)) return new Pie(container, options); 6 | options = options || {}; 7 | lodash.defaults(options, Pie.defaultOptions); 8 | this.options = options; 9 | var container = this.container = d3.select(container); 10 | this.data = options.data || container.attr('data-pie'); 11 | } 12 | 13 | Pie.defaultOptions = { 14 | colors: DEFAULT_COLORS, 15 | width: 150, 16 | height: 150, 17 | data: null, 18 | sort: null, 19 | val: function(d) { 20 | return d.value; 21 | }, 22 | textValue: function(d, i) { 23 | return d.data.value + '\n' + d.data.label; 24 | }, 25 | // offset from center point 26 | offsetUnit: 'em', 27 | textOffset: 'auto', 28 | // total offset 29 | textX: 0, 30 | textY: 0, 31 | textSize: "0.85em" 32 | }; 33 | 34 | Pie.prototype.draw = function() { 35 | var self = this; 36 | var options = self.options; 37 | var container = self.container; 38 | var width = options.width, height = options.height; 39 | var radius = options.radius || Math.min(width, height) / 2 - 10; 40 | var radiusInner = options.radiusInner || 0; 41 | 42 | var arc = self.arc = d3.svg.arc().outerRadius(radius).innerRadius(radiusInner); 43 | var pie = self.pie = d3.layout.pie().sort(options.sort).value(options.val); 44 | var color = self.color = d3_colors(options.colors); 45 | 46 | var svg = self.svg = self.container.append('svg') 47 | .attr('height', height).attr('width', width) 48 | .append('g') 49 | .attr("transform", "translate(" + width / 2 + "," + height / 2 + ")"); 50 | 51 | 52 | var data = d3.csv.parse(self.data); 53 | var g = svg.selectAll(".arc") 54 | .data(pie(data)) 55 | .enter().append("g") 56 | .attr("class", "arc"); 57 | 58 | g.append("path").attr("d", arc) 59 | .style("fill", function(d) { return color(d.data.label); }); 60 | 61 | // default text x y 62 | var textY = options.textY, textX = options.textX; 63 | var textZ = options.textOffset; 64 | var autoZ = textZ === 'auto'; 65 | var unit = autoZ ? '' : options.offsetUnit; 66 | 67 | var textValue = options.textValue; 68 | 69 | g.each(function(d, i) { 70 | var t = textValue(d); 71 | var r = autoZ ? radius / 6 : textZ; 72 | var c = (d.startAngle + d.endAngle) / 2; 73 | //* 90 / Math.PI; 74 | var x = Math.sin(c) * r; 75 | var y = - Math.cos(c) * r; 76 | x += textX; 77 | y += textY; 78 | 79 | var lines = t.split('\n'); 80 | // 0.17... = 10 * 3.14 / 180 81 | if (d.endAngle - d.startAngle < 0.17 * lines.length && c > 3.2) { 82 | y += lines.length * 7; 83 | } 84 | 85 | var text = svg.append("text") 86 | .attr("transform", "translate(" + arc.centroid(d) + ")") 87 | .attr("font-size", options.textSize) 88 | .style("text-anchor", "middle") 89 | 90 | if (options.textColor) { 91 | text.style("fill", options.textColor(d)); 92 | } 93 | 94 | lodash.each(lines, function(t, i) { 95 | text.append('tspan') 96 | .attr('x', 0) 97 | .attr('dx', x + unit) 98 | .attr('dy', i ? (i + 0.15 + 'em') : y + unit) 99 | .text(t); 100 | }); 101 | }); 102 | }; 103 | -------------------------------------------------------------------------------- /static/js/chart/treemap.js: -------------------------------------------------------------------------------- 1 | function Treemap(container, options) { 2 | if (!container) throw new Error('Must give a container for Treemap.'); 3 | if (!(this instanceof Treemap)) return new Treemap(container, options); 4 | options = lodash.defaults(options, Treemap.defaultOptions); 5 | 6 | var self = this; 7 | self.options = options; 8 | self.container = d3.select(container); 9 | self.data = options.data; 10 | 11 | d3.rebind(self, self.container, 'on'); 12 | } 13 | 14 | Treemap.defaultOptions = { 15 | colors: DEFAULT_COLORS, 16 | width: 600, 17 | height: 400, 18 | name_prop: '_id', 19 | href: null, 20 | value: function(d) { return 1; }, 21 | text: null, 22 | position: function() { 23 | this.style("left", function(d) { return d.x + "px"; }) 24 | .style("top", function(d) { return d.y + "px"; }) 25 | .style("width", function(d) { return Math.max(0, d.dx - 1) + "px"; }) 26 | .style("height", function(d) { return Math.max(0, d.dy - 1) + "px"; }); 27 | } 28 | }; 29 | 30 | Treemap.prototype.draw = function(data) { 31 | var self = this; 32 | 33 | data = data || this.data; 34 | 35 | var options = self.options 36 | , colors = options.colors 37 | , name_prop = options.name_prop 38 | , container = self.container 39 | , width = options.width 40 | , height = options.height; 41 | 42 | var color = colors ? d3_colors(colors) : d3.scale.category20c(); 43 | var text = options.text || function(d) { return d.children ? null : d[name_prop]; }; 44 | 45 | var treemap = self.treemap = d3.layout.treemap() 46 | .size([width, height]) 47 | .sticky(true) 48 | .value(options.value); 49 | 50 | var nodes = self.nodes = container.datum(data).selectAll('.node') 51 | .data(treemap.nodes) 52 | .enter().append('div') 53 | .attr('class', function(d) { return d.children ? 'node parent' : 'node'; }) 54 | .call(options.position) 55 | .style('background', function(d) { 56 | if (d.children) return color(d[name_prop]); 57 | var kid_len = d.parent.children.length; 58 | var factor = Math.pow(d.value / d.parent.value * kid_len, 0.29); 59 | return d3.hsl(color(d.parent[name_prop])).brighter(1.57).darker(factor); 60 | }) 61 | .append('a') 62 | .text(text); 63 | 64 | if (options.href) { 65 | nodes.attr('href', options.href); 66 | } 67 | 68 | return self; 69 | }; 70 | -------------------------------------------------------------------------------- /static/js/d3.js: -------------------------------------------------------------------------------- 1 | require.register("d3", function(exports, require, module){ 2 | // @import ../../bower_components/d3/d3.js 3 | module.exports = d3 4 | }); 5 | -------------------------------------------------------------------------------- /static/js/do.js: -------------------------------------------------------------------------------- 1 | // @import ./do.core.js 2 | // @import ./do.cmd.js 3 | -------------------------------------------------------------------------------- /static/js/homepage/ticker.js: -------------------------------------------------------------------------------- 1 | Do('lodash', function() { 2 | var tmpl = $.trim($('#tmpl-latest-synced').html()); 3 | var lodash = require('lodash')._; 4 | 5 | $.getJSON('/api/latest_synced', function(res) { 6 | if (!res || res.r) return; 7 | $('.ticker-content', '#latest-synced').html(lodash.template(tmpl, res)); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /static/js/lodash.js: -------------------------------------------------------------------------------- 1 | // @import ../../bower_components/lodash/dist/lodash.js 2 | -------------------------------------------------------------------------------- /static/js/main.js: -------------------------------------------------------------------------------- 1 | // @import ../../bower_components/jquery/dist/jquery.js 2 | require.register("jquery", function(exports, require, module){ 3 | module.exports = window.jQuery 4 | }) 5 | require.register("main", function(exports, require, module) { 6 | // @import ../../bower_components/bootstrap/dist/js/bootstrap.js 7 | 8 | // numbers 9 | var nums = $('span.num') 10 | if (nums.length) { 11 | Do('d3', function() { 12 | var d3 = require('/d3') 13 | var format = d3.format(',.2') 14 | nums.each(function() { 15 | var node = $(this) 16 | node.text(format(node.text())) 17 | }) 18 | }) 19 | } 20 | }) 21 | require.register("utils/datetime", function(exports, require, module) { 22 | // @import ./utils/datetime.js 23 | }) 24 | 25 | require('main') 26 | -------------------------------------------------------------------------------- /static/js/mine/booter.js: -------------------------------------------------------------------------------- 1 | Do('lodash', function() { 2 | var lodash = require('lodash')._; 3 | var tmpl_friends = lodash.template($('#tmpl-friends').html()); 4 | var tmpl_friends_items = lodash.template($('#tmpl-friends-items').html()); 5 | 6 | var uid = window._uid_; 7 | 8 | var API_FOLLOWINGS = '/api/mine/followings'; 9 | 10 | function preprocess(items) { 11 | lodash.each(items, function(item) { 12 | item.url = '/people/' + item.uid + '/'; 13 | }); 14 | } 15 | 16 | var followings = $('#followings'); 17 | var loading_html = followings.html(); 18 | 19 | var start = 48, limit = 24; 20 | 21 | function first_pull(fresh) { 22 | $.getJSON(API_FOLLOWINGS, { 23 | fresh: fresh, 24 | limit: 48 25 | }, function(res, err) { 26 | if (res.r) { 27 | if (res.msg === 'pulling') { 28 | res.msg = '同步正在进行..'; 29 | setTimeout(first_pull, 10000); 30 | } 31 | followings.find('.alert').addClass('alert-error').html(res.msg || '出错啦!'); 32 | return; 33 | } 34 | if (res.items) { 35 | preprocess(res.items); 36 | } 37 | followings.html(tmpl_friends(res)); 38 | followings.find('ul').html(tmpl_friends_items(res)); 39 | }); 40 | } 41 | first_pull(); 42 | 43 | followings.delegate('.btn-more', 'click', function(e) { 44 | e.preventDefault(); 45 | 46 | var node = $(this); 47 | if (node.hasClass('disabled')) return; 48 | 49 | node.addClass('disabled').html('加载中...'); 50 | 51 | $.getJSON(API_FOLLOWINGS, { start: start, limit: limit }, function(res, a) { 52 | start += limit; 53 | if (res.r) { 54 | node.remove(); 55 | alert('出错啦!' + res.msg); 56 | return; 57 | } 58 | if (res.items) { 59 | preprocess(res.items); 60 | } 61 | if (res.items.length < limit) { 62 | node.html('没有更多了'); 63 | } else { 64 | node.removeClass('disabled').html('加载更多友邻'); 65 | } 66 | followings.find('ul').append(tmpl_friends_items(res)); 67 | }); 68 | }); 69 | 70 | $('body').delegate('.clear-followings', 'click', function(e) { 71 | e.preventDefault(); 72 | followings.html(loading_html); 73 | first_pull(true); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /static/js/mine/click.js: -------------------------------------------------------------------------------- 1 | Do.loadClick = function(me, uid, container, success, failed) { 2 | var API_CLICK = '/api/people/' + me + '/click/' + uid; 3 | 4 | var stopped = false; 5 | setTimeout(function() { stopped = true; }, 60000); 6 | 7 | success = success || function(r) { 8 | Do('lodash', function() { 9 | r.score = r.score || 0; 10 | var lodash = require('lodash')._; 11 | var tmpl_click_index = lodash.template($('#tmpl-click-index').html()); 12 | $(container).html(tmpl_click_index(r)); 13 | }); 14 | } 15 | 16 | function check() { 17 | $.getJSON(API_CLICK, function(res) { 18 | if (res.r) return failed(); 19 | 20 | $(container).find('.progress-bar').width(res.p + '%'); 21 | 22 | if (res.p === 100) return success(res.result); 23 | 24 | if (!stopped) setTimeout(check, 3000); 25 | }, failed); 26 | } 27 | 28 | failed = failed || function failed() { 29 | $(container).find('small').html('获取信息失败... 正在重试..'); 30 | setTimeout(check, 7000); 31 | } 32 | 33 | Do(check); 34 | }; 35 | -------------------------------------------------------------------------------- /static/js/people/abbrs.js: -------------------------------------------------------------------------------- 1 | var abbrs = { 2 | '中国人民大学': '中国人大', 3 | '外语教学与研究出版社': '外研社', 4 | '社会科学': '社科', 5 | '科学技术': '科技', 6 | '科技教育': '科教', 7 | '师范大学': '师大', 8 | '美术学院': '美院', 9 | '北京大学': '北大', 10 | '生活·读书·新知三联': '三联', 11 | '上海三联书店': '上海三联', 12 | '[\\[\\((](法|美|英|德|日|意(大利)?|西(班牙)?|土耳其|捷克)[\\]\\))]': '', 13 | '[\\[\\((](主?编)[\\]\\))]': '', 14 | '译文出版社': '译文' 15 | }; 16 | -------------------------------------------------------------------------------- /static/js/people/bars.js: -------------------------------------------------------------------------------- 1 | var charts = []; 2 | $('div.chart-bar').each(function(i, item) { 3 | var node = $(item); 4 | 5 | var w = node.width(); 6 | var h = node.height(); 7 | var bar = chart.Bar(item, { 8 | margin: [40, 20, 40, 40], 9 | legendTransform: function(d, i) { 10 | return "translate(" + -(i * 50 + 30) + "," + (h - 50) + ")"; 11 | }, 12 | width: w, 13 | height: h 14 | }); 15 | 16 | item._bar = bar; 17 | 18 | charts.push([node, bar]); 19 | }); 20 | 21 | $('input[name=bar-style]').bind('change', function(e) { 22 | var elem = this; 23 | if (elem.checked) { 24 | var fn = elem.value; 25 | $(elem).closest('.row').find('div.chart-bar').each(function(i, item) { 26 | if (item._bar) { 27 | item._bar[fn](); 28 | setTimeout(function() { 29 | item._bar.updateY(); 30 | }, 1000) 31 | } 32 | }); 33 | } 34 | }); 35 | 36 | var _t; 37 | var win = $(window); 38 | win.scroll(function(e) { 39 | _t && clearTimeout(_t); 40 | _t = setTimeout(function() { 41 | lazycheck(); 42 | }, 200); 43 | }); 44 | 45 | function lazycheck() { 46 | while (charts[0] && charts[0][0].offset().top < win.scrollTop() + win.height() + 100) { 47 | charts.shift()[1].draw(); 48 | } 49 | } 50 | lazycheck(); 51 | 52 | $('div.chart').delegate('.chart-filter li', 'click', function(e) { 53 | e.preventDefault(); 54 | var node = $(this); 55 | var bar = node.parent().nextAll('.chart-bar')[0]._bar; 56 | var arg = node.find('a').attr('href').split(':'); 57 | 58 | node.addClass('active').siblings().removeClass('active'); 59 | 60 | // call filter function 61 | bar[arg[0] + '_filter'](arg[1]); 62 | }); 63 | -------------------------------------------------------------------------------- /static/js/people/booter.js: -------------------------------------------------------------------------------- 1 | //@@nowrap 2 | Do('lodash', 'd3', 'chart/all', function(_require) { 3 | var d3 = require('d3'); 4 | var chart = require('chart/all'); 5 | var lodash = require('lodash')._; 6 | 7 | var d_summary = $('#d-summary'); 8 | if (d_summary.length) { 9 | chart.Pie(d_summary[0], { 10 | width: d_summary.width(), 11 | height: d_summary.height() - 10, 12 | textValue: function(d) { 13 | return d.data.value + '%' + '\n' + d.data.label; 14 | }, 15 | }).draw(); 16 | } 17 | 18 | // @import ./bars.js 19 | 20 | var tag_props = { tags: 1, personal_tags: 1, public_tags: 1 }; 21 | function search_url(ns, txt) { 22 | return 'http://' + ns + '.douban.com/subject_search?search_text=' + encodeURIComponent(txt); 23 | } 24 | function tag_url(ns, txt) { 25 | return '/tag/' + encodeURIComponent(txt); 26 | } 27 | 28 | // Abbreviations for publisher / autho 29 | // @import ./abbrs.js 30 | 31 | var treemaps = $('div.chart-treemap'); 32 | 33 | treemaps.each(function(i, item) { 34 | var d_treemap = $(item); 35 | var ns = d_treemap.data('ns'); 36 | var c_treemap = new chart.Treemap(d_treemap[0], { 37 | width: d_treemap.width(), 38 | text: function(d) { 39 | var t = d._id; 40 | if (d.children || !t) return null; 41 | for (var k in abbrs) { 42 | t = t.replace(new RegExp(k), abbrs[k]); 43 | } 44 | return t; 45 | }, 46 | height: d_treemap.height(), 47 | value: function(d) { 48 | return d.count * (d.factor || 1); 49 | }, 50 | href: function(d) { 51 | if (d.children) return null; 52 | if (d.parent && d.parent._id in tag_props) return tag_url(ns, d._id); 53 | return search_url(ns, d._id); 54 | } 55 | }); 56 | c_treemap.draw(d_treemap.data('tree')); 57 | c_treemap.nodes.attr('target', 'db-book') 58 | .attr('title', function(d) { return d.children ? '' : d._id + '(' + d.count + '本)'; }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /static/js/people/crossfilter.js: -------------------------------------------------------------------------------- 1 | // ========== for crossfilter detailed view ======= 2 | function get_detailed(bar, list) { 3 | var crossfilter = require('crossfilter').crossfilter; 4 | 5 | function dataList(div) { 6 | var data = bar.parsed_data; 7 | } 8 | 9 | list = d3.selectAll(list).data([dataList]); 10 | 11 | var brush = d3.svg.brush(), 12 | brushDirty, 13 | dimension, 14 | group, 15 | round; 16 | 17 | var g = bar.svg.append('g.brush-layer'); 18 | 19 | // Initialize the brush component with pretty resize handles. 20 | var gBrush = g.append("g").attr("class", "brush").call(brush); 21 | 22 | gBrush.selectAll("rect").attr("height", height); 23 | gBrush.selectAll(".resize").append("path").attr("d", resizePath); 24 | 25 | // Only redraw the brush if set externally. 26 | if (brushDirty) { 27 | brushDirty = false; 28 | g.selectAll(".brush").call(brush); 29 | div.select(".title a").style("display", brush.empty() ? "none" : null); 30 | if (brush.empty()) { 31 | g.selectAll("#clip-" + id + " rect") 32 | .attr("x", 0) 33 | .attr("width", width); 34 | } else { 35 | var extent = brush.extent(); 36 | g.selectAll("#clip-" + id + " rect") 37 | .attr("x", x(extent[0])) 38 | .attr("width", x(extent[1]) - x(extent[0])); 39 | } 40 | } 41 | 42 | function barPath(groups) { 43 | var path = [], 44 | i = -1, 45 | n = groups.length, 46 | d; 47 | while (++i < n) { 48 | d = groups[i]; 49 | path.push("M", x(d.key), ",", height, "V", y(d.value), "h9V", height); 50 | } 51 | return path.join(""); 52 | } 53 | 54 | function resizePath(d) { 55 | var e = +(d == "e"), 56 | x = e ? 1 : -1, 57 | y = height / 3; 58 | return "M" + (.5 * x) + "," + y 59 | + "A6,6 0 0 " + e + " " + (6.5 * x) + "," + (y + 6) 60 | + "V" + (2 * y - 6) 61 | + "A6,6 0 0 " + e + " " + (.5 * x) + "," + (2 * y) 62 | + "Z" 63 | + "M" + (2.5 * x) + "," + (y + 8) 64 | + "V" + (2 * y - 8) 65 | + "M" + (4.5 * x) + "," + (y + 8) 66 | + "V" + (2 * y - 8); 67 | } 68 | 69 | 70 | brush.on("brushstart.chart", function() { 71 | var div = d3.select(this.parentNode.parentNode.parentNode); 72 | div.select(".title a").style("display", null); 73 | }); 74 | 75 | } 76 | 77 | function detail_expand() { 78 | var trigger = $(this); 79 | var elem = this; 80 | trigger.siblings('.details-content').show(300); 81 | 82 | if (elem.detailed) return elem.detailed.show(); 83 | 84 | Do('crossfilter', function() { 85 | var list = trigger.siblings('.details-content')[0]; 86 | var bar = $(elem.href.split(':')[1])[0]._bar; 87 | elem.detailed = get_detailed(bar, list); 88 | }); 89 | } 90 | 91 | function detail_collapse(e) { 92 | $(this).siblings('.details-content').hide(400); 93 | } 94 | 95 | $('.details-toggler').toggle(detail_expand, detail_collapse); 96 | 97 | 98 | -------------------------------------------------------------------------------- /static/js/people/progress.js: -------------------------------------------------------------------------------- 1 | Do.ready(function() { 2 | 3 | var datetime = require('utils/datetime'); 4 | var prog = $('#progress'); 5 | var remains = $('#remains'); 6 | var ad = false; // 是否附上捐赠广告 7 | 8 | var total = 0; 9 | function check(dur) { 10 | total++; 11 | 12 | // stop after 100 request 13 | if (total > 100) return; 14 | 15 | if (dur < 500) dur = 500; 16 | 17 | setTimeout(function() { 18 | $.getJSON('/api/people/' + window._uid_ + '/progress', function(d) { 19 | // failed, retry after 10 secs 20 | if (!d || d.r) return check(10000); 21 | 22 | updateProgress(d.percents); 23 | 24 | updateRemains(d); 25 | 26 | if (d.stats_status && d.stats_status !== 'ing') { 27 | setTimeout(function() { 28 | window.location.reload(); 29 | }, 3000); 30 | return; 31 | } 32 | 33 | if (d.remaining < 300000) { 34 | check(d.interval || 5000); 35 | } 36 | }); 37 | }, dur || 3000); 38 | } 39 | check(); 40 | 41 | function updateProgress(d) { 42 | prog.find('.progress-bar').each(function(i, item) { 43 | $(item).css('width', (d[i] || 0) + '%'); 44 | }); 45 | } 46 | 47 | function updateRemains(d) { 48 | var t = d.remaining; 49 | var text = ''; 50 | 51 | ad = !ad; 52 | 53 | if (d.book_synced_n < d.book_n) { 54 | text = '

' 55 | text += '已同步 ' + d.book_synced_n + '/' + d.book_n + ' 本图书收藏,'; 56 | if (d.queue_length > 2) { 57 | text += '共有' + d.queue_length + '人同时在排队'; 58 | } 59 | } 60 | 61 | if (t > 60000) { 62 | text += '

预计还需要' + datetime.mili2chinese(t, true) + '左右'; 63 | } else if (t > 5000) { 64 | text += '

只剩下大概' + datetime.mili2chinese(t); 65 | } else if (t) { 66 | text = '马上就好'; 67 | } 68 | text = text || '

同步仍在进行,请耐心等待..

'; 69 | if (ad) { 70 | text += '

太慢了? 捐点钱让服务器更快!' 71 | } 72 | remains.html(text); 73 | } 74 | 75 | // refresh page after five minutes, no matter what 76 | setTimeout(function() { 77 | location.reload(); 78 | }, 300000); 79 | }); 80 | -------------------------------------------------------------------------------- /static/js/utils/datetime.js: -------------------------------------------------------------------------------- 1 | function mili2united(timediff) { 2 | // strip the miliseconds 3 | timediff = Math.round(timediff / 1000); 4 | 5 | // get seconds 6 | var sec = timediff % 60; 7 | 8 | // remove seconds from the date 9 | timediff = Math.floor(timediff / 60); 10 | 11 | // get minutes 12 | var mi = timediff % 60; 13 | 14 | // remove minutes from the date 15 | timediff = Math.floor(timediff / 60); 16 | 17 | // get hours 18 | var h = timediff % 24; 19 | 20 | // remove hours from the date 21 | timediff = Math.floor(timediff / 24); 22 | 23 | // the rest of timediff is number of days 24 | var d = timediff; 25 | 26 | return [d,h,mi,sec]; 27 | } 28 | 29 | function mili2chinese(timediff, no_sec) { 30 | var u = mili2united(timediff) 31 | , d = u[0], h = u[1] 32 | , mi = u[2], sec = u[3]; 33 | 34 | if (no_sec) { 35 | mi = sec < 30 ? mi : mi + 1; 36 | } 37 | return (d ? d + '天' : '') + 38 | (h ? h + '小时' : '') + 39 | (mi ? mi + '分' + (no_sec ? '钟' : '') : '') + 40 | (no_sec ? '' : (u[3] || 0) + '秒'); 41 | } 42 | 43 | module.exports = { 44 | mili2united: mili2united, 45 | mili2chinese: mili2chinese 46 | }; 47 | -------------------------------------------------------------------------------- /static/pics/alipay-qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktmud/doubanj/f7b5cc08fcb878ec655def7df7c7ff20244cdce9/static/pics/alipay-qrcode.png -------------------------------------------------------------------------------- /static/pics/blank.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktmud/doubanj/f7b5cc08fcb878ec655def7df7c7ff20244cdce9/static/pics/blank.gif -------------------------------------------------------------------------------- /static/pics/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktmud/doubanj/f7b5cc08fcb878ec655def7df7c7ff20244cdce9/static/pics/favicon.ico -------------------------------------------------------------------------------- /static/pics/login_with_douban_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktmud/doubanj/f7b5cc08fcb878ec655def7df7c7ff20244cdce9/static/pics/login_with_douban_32.png -------------------------------------------------------------------------------- /static/pics/wechat-qrcode.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktmud/doubanj/f7b5cc08fcb878ec655def7df7c7ff20244cdce9/static/pics/wechat-qrcode.jpg -------------------------------------------------------------------------------- /static/pics/wechat-qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktmud/doubanj/f7b5cc08fcb878ec655def7df7c7ff20244cdce9/static/pics/wechat-qrcode.png -------------------------------------------------------------------------------- /tasks/README.md: -------------------------------------------------------------------------------- 1 | # Tasks for doubanj.com 2 | 3 | All the backend data computing task 4 | 5 | ## 队列保护机制 6 | 7 | 任务开始后会加入队列(记录为其方法名和参数列表),队列会自动同步到 redis ,如果服务重启,会重新开始队列。 8 | 9 | 重新开始的任务,会在参数中收到 `_from_halt` 标记。 10 | 11 | 为了达到目的,有此约定: 12 | 13 | 1. 每一个 task 的 module.exports 都是一系列方法名 14 | 2. 每一个方法接受的参数一般为一个 dict object,包括 success 和 error 的回调处理 15 | 3. 在此方法开始前使用 `module.exports.queue.safely('方法名', args)` 将任务加入队列暂存 16 | 4. 之后只需在任务结束后正常得调用 `arg.success` 或 `arg.error` ,会自动释放队列 17 | 4. 若任务被迫中断,重启后会使用同样的方法名和参数重新执行任务 18 | -------------------------------------------------------------------------------- /tasks/click/index.js: -------------------------------------------------------------------------------- 1 | var debug = require('debug'); 2 | var log = debug('dbj:task:click:info'); 3 | var error = debug('dbj:task:click:error'); 4 | 5 | var central = require(process.cwd() + '/lib/central'); 6 | 7 | var cwd = central.cwd; 8 | 9 | var consts = require(cwd + '/models/consts'); 10 | var User = require(cwd + '/models/user'); 11 | 12 | var utils = central.utils; 13 | var task = central.task; 14 | var mongo = central.mongo; 15 | var raven = central.raven; 16 | 17 | var compute = task.compute_pool.pooled(function calculateClick(computings, arg, next) { 18 | var called = false; 19 | 20 | var error_cb = function(err) { 21 | arg.error(err); 22 | next(); 23 | }; 24 | var succeed_cb = function(result) { 25 | if (called) return; 26 | arg.success(result); 27 | called = true; 28 | next(); 29 | }; 30 | 31 | var users = arg.users; 32 | var is_valid = true; 33 | 34 | if (Array.isArray(users)) { 35 | for (var k in users) { 36 | if (users[k] instanceof User) continue; 37 | is_valid = false; 38 | break; 39 | } 40 | // 需要按照用户 id 排序 41 | // 为避免数字implicit类型转化, 42 | // 不要直接相减 43 | users = users.sort(function(a, b) { 44 | if (a.id > b.id) return 1; 45 | return -1; 46 | }); 47 | } else { 48 | is_valid = false; 49 | } 50 | 51 | if (!is_valid) { 52 | error('invalid users: %s', users); 53 | return error_cb(new Error('Invalid arguments')); 54 | } 55 | 56 | var uids = users.map(function(item) { return item.uid; }); 57 | 58 | log('calculating click for %s', uids); 59 | 60 | require('./' + arg.job)(users, function(err, r) { 61 | if (err) return error_cb(err); 62 | succeed_cb(r); 63 | }); 64 | }); 65 | 66 | var exports = {}; 67 | 68 | function compute_by_ns(ns) { 69 | return function(arg) { 70 | module.exports.queue.safely(ns, arg); 71 | arg.job = ns; 72 | compute(arg); 73 | } 74 | } 75 | 76 | central.DOUBAN_APPS.forEach(function(ns) { 77 | exports[ns] = compute_by_ns(ns); 78 | }); 79 | 80 | module.exports = exports; 81 | -------------------------------------------------------------------------------- /tasks/index.js: -------------------------------------------------------------------------------- 1 | var tasks = {}; 2 | 3 | module.exports = tasks 4 | 5 | var central = require('../lib/central'); 6 | var Resumable = require('resumable'); 7 | var User = require('../models/user'); 8 | 9 | var redis = central.redis; 10 | 11 | var debug = require('debug'); 12 | var verbose = debug('dbj:tasks:verbose'); 13 | var log = debug('dbj:tasks:log'); 14 | 15 | var names = ['interest', 'compute', 'click']; 16 | 17 | names.forEach(function(item) { 18 | var mod = tasks[item] = require('./' + item); 19 | 20 | var queue = mod.queue = new Resumable({ 21 | key: 'doubanj-queue-' + item, 22 | mod: mod, 23 | storage: redis.client, 24 | autoLoad: true, 25 | ensure: function(list) { 26 | var seen = {}; 27 | var ret = list.filter(function(arg) { 28 | if (!arg[0] || !arg[1]) return false; 29 | if (arg[1].user in seen) return false; 30 | seen[arg[1].user] = 1; 31 | return true; 32 | }); 33 | return ret; 34 | }, 35 | stringify: function(k, v) { 36 | if (v instanceof User) { 37 | return v.uid || v._id; 38 | } 39 | return v; 40 | } 41 | }); 42 | 43 | // let the queue resume undone works. 44 | queue.on('ready', function(q) { 45 | verbose('Task queue for ' + item + ' loaded.'); 46 | log('%s unfinished task for %s', q.length, item); 47 | this.resume(); 48 | }); 49 | 50 | queue.on('dumped', function() { 51 | log('Queue %s dumped.', queue.key); 52 | }); 53 | 54 | tasks[item + '_queue'] = queue; 55 | }); 56 | 57 | tasks.setKeyPrefix = function(prefix) { 58 | names.forEach(function(item) { 59 | tasks[item + '_queue'].key = prefix + item; 60 | }); 61 | } 62 | 63 | tasks.getQueueLength = function(name) { 64 | if (!name) { 65 | var total = 0 66 | names.forEach(function(item) { 67 | total += tasks.getQueueLength(item) 68 | }) 69 | return total 70 | } 71 | var queue = tasks.getQueue(name) 72 | if (queue) { 73 | return queue.queue.length 74 | } 75 | return 0 76 | } 77 | 78 | tasks.getQueue = function(name) { 79 | return tasks[name + '_queue'] 80 | } 81 | -------------------------------------------------------------------------------- /tasks/interest/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * aggregate user subject collections (called "interest") 3 | */ 4 | var central = require(process.cwd() + '/lib/central'); 5 | var utils = central.utils; 6 | var async = require('async'); 7 | 8 | var raven = central.raven; 9 | 10 | var User = require(central.cwd + '/models/user'); 11 | var FetchStream = require('./stream'); 12 | 13 | function error() { 14 | var args = [].slice.apply(arguments); 15 | var extra = args[args.length - 1] || {}; 16 | args[args.length - 1] = { tags: { task: 'collect' }, extra: extra }; 17 | raven.error.apply(raven, args); 18 | } 19 | function message() { 20 | var args = [].slice.apply(arguments); 21 | var extra = args[args.length - 1] || {}; 22 | args[args.length - 1] = { tags: { task: 'collect' }, extra: extra }; 23 | raven.message.apply(raven, args); 24 | } 25 | 26 | var collect, _collect; 27 | collect = User.ensured(function(user, arg) { 28 | if (!user) return arg.error('NO_USER'); 29 | 30 | arg.user = user; 31 | 32 | // try update user info 33 | if (arg.fresh && !arg._from_halt) { 34 | setImmediate(function() { 35 | user.pull(); 36 | }); 37 | } 38 | 39 | var uid = user.uid || user.id; 40 | 41 | var raven_extra = { ns: arg.ns, uid: uid }; 42 | message('START collect interests for %s', uid, raven_extra); 43 | 44 | var collector = new FetchStream(arg); 45 | 46 | // halt if syncing is already running 47 | if (user.last_synced_status === 'ing' && !arg.force && !arg._from_halt) { 48 | message('EXIT collect for %s due to runing..', uid, raven_extra); 49 | arg.error(new Error('Already running collecting process.')); 50 | return; 51 | } 52 | 53 | collector.on('error', function(err) { 54 | console.error(err); 55 | error(err, raven_extra); 56 | 57 | collector.status = 'failed'; 58 | collector.updateUser(function() { 59 | collector.end(); 60 | }); 61 | }); 62 | 63 | collector.on('saved', function(data) { 64 | collector.updateUser(); 65 | }); 66 | 67 | collector.once('end', function() { 68 | // wait for the really ends 69 | setTimeout(function() { 70 | if (collector.status == 'succeed') { 71 | message('SUCCEED collect interests for %s', uid, raven_extra); 72 | 73 | arg.success.call(collector, user); 74 | 75 | collector.emit('succeed'); 76 | } else { 77 | raven_extra.status = collector.status; 78 | error('Collect failed.', raven_extra); 79 | arg.error.call(collector, user); 80 | } 81 | }, 2000); 82 | }); 83 | 84 | collector.once('succeed', function() { 85 | // run compute task right after success 86 | require('../compute')[arg.ns]({ 87 | user: user, 88 | force: true, 89 | success: function() { 90 | user.emit('computed') 91 | }, 92 | error: function(err) { 93 | user.emit('computed', err) 94 | } 95 | }) 96 | }); 97 | 98 | collector.run(); 99 | }); 100 | 101 | function is_night() { 102 | var h = (new Date()).getHours(); 103 | return h > 1 && h < 9; 104 | } 105 | 106 | function collect_in_namespace(ns) { 107 | return function(arg) { 108 | // queue arguments safely 109 | exports.queue.safely('collect_' + ns, arg); 110 | 111 | arg.ns = ns; 112 | 113 | collect(arg.user, arg); 114 | }; 115 | } 116 | 117 | var exports = {}; 118 | 119 | central.DOUBAN_APPS.forEach(function(item) { 120 | exports['collect_' + item] = collect_in_namespace(item); 121 | }); 122 | 123 | // collect all the interest 124 | exports.collect_all = function(user, succeed_cb, error_cb) { 125 | var called = false; 126 | var error_next = function(err) { 127 | if (called) return; 128 | called = true; 129 | error_cb && error_cb(err); 130 | } 131 | if (!user) return error_next('NO_USER'); 132 | 133 | var apps = central.DOUBAN_APPS; 134 | var collectors = []; 135 | (function run(i) { 136 | var ns = apps[i]; 137 | // all apps proceeded 138 | if (!ns) succeed_cb && succeed_cb(collectors); 139 | exports['collect_' + item]({ 140 | user: user, 141 | success: function(collectors) { 142 | collectors.push(collector); 143 | run(i+1); 144 | }, 145 | error: error_next 146 | }); 147 | })(0); 148 | }; 149 | exports.collect = collect; 150 | 151 | module.exports = exports; 152 | -------------------------------------------------------------------------------- /tasks/quotes/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktmud/doubanj/f7b5cc08fcb878ec655def7df7c7ff20244cdce9/tasks/quotes/index.js -------------------------------------------------------------------------------- /tasks/toplist/_obsolete.js: -------------------------------------------------------------------------------- 1 | 2 | function mapreduce_hardest_reader(period, cb) { 3 | cb = cb || function(){}; 4 | 5 | var map = function() { emit(this.user_id, 1); }; 6 | var reduce = function(k, vals) { return Array.sum(vals); }; 7 | 8 | // at least four words will be consider useful 9 | var query = { commented: { $gt: 3 } }; 10 | 11 | period = period || 'all_time'; 12 | 13 | var now = new Date(); 14 | switch(period) { 15 | case 'last_30_days': 16 | query.updated = { 17 | $gte: new Date(now - ONE_MONTH) 18 | }; 19 | break; 20 | case 'this_year': 21 | query.updated = { 22 | $gte: new Date('' + now.getFullYear()) 23 | }; 24 | break; 25 | case 'last_year': 26 | query.updated = { 27 | $gte: new Date('' + (now.getFullYear() - 1)), 28 | $lt: new Date('' + now.getFullYear()) 29 | }; 30 | break; 31 | case 'last_12_month': 32 | query.updated = { 33 | $gt: new Date(now - ONE_MONTH * 12), 34 | }; 35 | break; 36 | default: 37 | break; 38 | } 39 | 40 | var out_coll = 'book_done_count_' + period; 41 | 42 | mongo.queue(function(db, next) { 43 | db.collection('book_interest').mapReduce(map, reduce, { 44 | query: query, 45 | sort: { user_id: 1 }, 46 | //out: { inline: 1 }, 47 | out: { replace: out_coll }, 48 | }, function(err, coll) { 49 | next(); 50 | if (err) { 51 | error('Toplist failed: ', err) 52 | return cb(err); 53 | } 54 | log('Toplist for %s generated', out_coll); 55 | coll.ensureIndex({ value: -1 }, { background: true }, cb); 56 | }); 57 | }, 5); // 5 means low priority 58 | } 59 | 60 | -------------------------------------------------------------------------------- /tasks/toplist/index.js: -------------------------------------------------------------------------------- 1 | var async = require('async') 2 | var tasks = require('../../tasks') 3 | var central = require('../../lib/central') 4 | var cached = require('../../lib/cached') 5 | var getToplistKey = require('../../models/toplist').getToplistKey 6 | var mongo = central.mongo 7 | 8 | var debug = require('debug') 9 | var log = debug('dbj:toplist:log') 10 | var verbose = debug('dbj:toplist:verbose') 11 | var error = debug('dbj:toplist:error') 12 | 13 | var ONE_MONTH = 60 * 60 * 24 * 1000 * 30.5 14 | 15 | function aggregate_hardest_reader(period, cb) { 16 | cb = cb || function(){} 17 | period = period || 'all_time' 18 | 19 | var key = getToplistKey('book', period) 20 | var now = new Date() 21 | var query = { 22 | status: 'done', 23 | commented: { $gt: 3 } 24 | } 25 | var maxTime = 600000 // defaults to 10 min timeout 26 | var limit = 500 27 | switch(period) { 28 | case 'last_30_days': 29 | query.updated = { 30 | $gte: new Date(now - ONE_MONTH) 31 | } 32 | maxTime = 60000 // max 1 min for last 20 days 33 | break 34 | case 'this_year': 35 | query.updated = { 36 | $gte: new Date('' + now.getFullYear()) 37 | } 38 | break 39 | case 'last_year': 40 | query.updated = { 41 | $gte: new Date('' + (now.getFullYear() - 1)), 42 | $lt: new Date('' + now.getFullYear()) 43 | } 44 | break 45 | case 'last_12_month': 46 | query.updated = { 47 | $gt: new Date(now - ONE_MONTH * 12), 48 | } 49 | break 50 | case 'all_time': 51 | limit = 2000 52 | maxTime = 1800000 // max 30min for calculating all time top reader 53 | break 54 | default: 55 | break 56 | } 57 | 58 | var pipe = [ 59 | { $match: query }, 60 | { $project: { user_id: 1 } }, 61 | { $group: { 62 | _id: "$user_id", 63 | value: { $sum: 1 } 64 | }}, 65 | { $sort: { value: -1 } }, 66 | { $limit: limit } 67 | ] 68 | 69 | mongo.queue(function(db, next) { 70 | log('Generating Toplist for %s...', key) 71 | db.collection('book_interest').aggregate(pipe, { 72 | allowDiskUse: true, 73 | maxTimeMS: maxTime 74 | }, function(err, results) { 75 | next() 76 | if (err) { 77 | error('Toplist failed: ', err) 78 | return cb(err) 79 | } 80 | log('Toplist for %s generated.', key) 81 | cached.set(key, results, cb) 82 | cached.del(key + '_cached', function(){}) 83 | }) 84 | }, 5) // 5 means low priority 85 | } 86 | 87 | exports.hardest_reader = aggregate_hardest_reader 88 | exports.by_tag = require('./by_tag') 89 | 90 | 91 | function breakable(period) { 92 | return function(callback) { 93 | // if some task is running, break 94 | if (tasks.getQueueLength()) { 95 | log('Exit compute toplist %s due to running computing.', period) 96 | return callback() 97 | } 98 | aggregate_hardest_reader(period, callback) 99 | } 100 | } 101 | 102 | exports.run = function(total, callback) { 103 | var jobs = [] 104 | // 收藏数量太少的用户对最终结果应该也没什么影响 105 | if (total > 200) { 106 | jobs.push(breakable('last_30_days')) 107 | } 108 | if (total > 500) { 109 | jobs.push(breakable('last_12_month')) 110 | } 111 | if (total > 2000) { 112 | jobs.push(breakable('all_time')) 113 | } 114 | if (jobs.length) { 115 | async.series(jobs, callback) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tasks/utils/index.js: -------------------------------------------------------------------------------- 1 | var raven = require('../../lib/raven'); 2 | 3 | var normalize_status = { 4 | 'reading': 'ing', 5 | 'read': 'done', 6 | 'wish': 'wish', 7 | }; 8 | function norm_status(s) { 9 | return normalize_status[s]; 10 | } 11 | 12 | var parsers = require('./parser'); 13 | function norm_subject(s, ns) { 14 | if (!s) { 15 | return s 16 | } 17 | s._id = String(s.id); 18 | delete s.id; 19 | 20 | if (ns) { 21 | s.type = ns; 22 | } else if ('book_id' in s) { 23 | s.type = book; 24 | } 25 | var k, oril 26 | for (k in parsers) { 27 | if (k in s) { 28 | ori = s['ori_' + k] = s[k]; // backup original value 29 | s[k] = parsers[k](s[k]); 30 | if (s[k] && ori && s[k] === null) { 31 | console.log('invalid %s %s', k, ori); 32 | raven.message('invalid %s %s', k, ori, { 33 | tags: { parsing: k }, 34 | extra: { 35 | subject_id: s._id 36 | }, 37 | level: 'warn' 38 | }); 39 | } 40 | } 41 | } 42 | if (s.rating) { 43 | s.raters = s.rating.numRaters; 44 | // zero is ignored 45 | s.rated = parseFloat(s.rating.average, 10) || null; 46 | } 47 | // remove useless props 48 | delete s.url; 49 | delete s.alt; 50 | 51 | return s; 52 | } 53 | function norm_interest(i) { 54 | i._id = String(i.id); 55 | delete i.id; 56 | 57 | i.status = normalize_status[i.status]; 58 | i.commented = i.comment && i.comment.length || null; 59 | i.updated = new Date(i.updated + '+800'); 60 | return i; 61 | } 62 | 63 | var time_funcs = { 64 | year: function(date) { return date.getFullYear() + '' }, 65 | month: function(date) { return date.getFullYear() + '-' + date.getMonth() }, 66 | monthday: function(date) { return date.getDate() + '' }, 67 | weekday: function(date) { return date.getDay() + '' }, 68 | hour: function(date) { return date.getHours() + '' }, 69 | }; 70 | 71 | module.exports = { 72 | parsers: parsers, 73 | norm_subject: norm_subject, 74 | norm_interest: norm_interest, 75 | time_funcs: time_funcs 76 | }; 77 | -------------------------------------------------------------------------------- /templates/403.jade: -------------------------------------------------------------------------------- 1 | extend layout/message 2 | - title = '没有权限' 3 | 4 | block message 5 | h1 Oops... 6 | p 你好像没有权限获得这个页面 7 | -------------------------------------------------------------------------------- /templates/404.jade: -------------------------------------------------------------------------------- 1 | extend layout/message 2 | - title = '找不到' 3 | 4 | block message 5 | h1 Hooray! 6 | p 你来到了火星!欢呼一下吧~~~ 7 | -------------------------------------------------------------------------------- /templates/500.jade: -------------------------------------------------------------------------------- 1 | extend layout/message 2 | 3 | block title 4 | title OH, NO! 5 | 6 | block message 7 | if !conf.debug 8 | h1 Oops... 9 | p 服务器出错了,呵呵呵... 10 | 11 | block stack 12 | if conf.debug 13 | - stack = locals.stack || locals.err && locals.err.stack 14 | if stack 15 | pre.code 16 | !{stack} 17 | else if typeof locals.err == 'object' 18 | - err = locals.err 19 | pre 20 | dl.dl-horizontal 21 | each v, k in err 22 | dt #{k} 23 | dd #{v} 24 | -------------------------------------------------------------------------------- /templates/auth/login.jade: -------------------------------------------------------------------------------- 1 | extend ../layout/basic 2 | 3 | block title 4 | title 5 | 登录 - #{conf.site_name} 6 | 7 | block head_more 8 | link(rel='stylesheet', href=static('/css/mine.css')) 9 | 10 | block main 11 | .row.logins 12 | .col.col-md-12.douban-login 13 | h2 通过豆瓣验证登录 14 | p 15 | include ./mods/douban 16 | //.col.col-md-.splitter 17 | //.col.col-md-6 18 | //include ./mods/login_form.jade 19 | -------------------------------------------------------------------------------- /templates/auth/mods/douban.jade: -------------------------------------------------------------------------------- 1 | a(href="/auth/douban", title="使用豆瓣账号登录") 2 | img(alt="使用豆瓣账号登录", src="#{static('/pics/login_with_douban_32.png')}") 3 | 4 | -------------------------------------------------------------------------------- /templates/auth/mods/login_form.jade: -------------------------------------------------------------------------------- 1 | h2 已有豆瓣酱账号,直接登录 2 | form.form-horizontal(method="post", action="#{conf.ssl_root}/login") 3 | input(type="hidden", name="_csrf", value="#{_csrf}") 4 | .form-group 5 | label.control-label(for="username") 用户名/邮箱 6 | .controls 7 | input#username.form-control(name="username", placeholder="yourname@example.com", autofocus, value=fields.username, type="text") 8 | .form-group 9 | label.control-label(for="password") 密码 10 | .controls 11 | input#password.form-control(name="password", placeholder="你的密码", type="password") 12 | .form-group 13 | .controls 14 | button.btn.btn-primary(type="submit") 15 | 登录 16 | 17 | -------------------------------------------------------------------------------- /templates/common/footer.jade: -------------------------------------------------------------------------------- 1 | #footer 2 | .container 3 | p.text-muted.site-links 4 | a(href="http://github.com/ktmud/doubanj", target="github", title="doubanj on github") 源码 5 | -------------------------------------------------------------------------------- /templates/common/navbar.jade: -------------------------------------------------------------------------------- 1 | if !locals.navbar_links 2 | - return 3 | .navbar.navbar-default 4 | .container 5 | .navbar-header 6 | button.navbar-toggle(data-toggle="collapse", data-target=".navbar-ex1-collapse") 7 | span.sr-only Toggle navigation 8 | span.icon-bar 9 | span.icon-bar 10 | span.icon-bar 11 | a.navbar-brand(href=conf.site_root) #{conf.site_name} 12 | -------------------------------------------------------------------------------- /templates/common/track.jade: -------------------------------------------------------------------------------- 1 | script. 2 | var _gaq = _gaq || []; 3 | _gaq.push(['_setAccount', 'UA-39104275-1']); 4 | _gaq.push(['_trackPageview']); 5 | function _insert(url) { 6 | var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; 7 | ga.src = url; 8 | var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s); 9 | } 10 | _insert('//www.google-analytics.com/ga.js'); 11 | _insert('//tajs.qq.com/stats?sId=24297157'); 12 | -------------------------------------------------------------------------------- /templates/index.jade: -------------------------------------------------------------------------------- 1 | extend layout/basic 2 | 3 | title = conf.site_name 4 | 5 | block main 6 | 由于豆瓣中止了免费的开放API,本站即将于2月底下线。感谢你的陪伴。 7 | 8 | block ending 9 | script. 10 | Do.urls(!{urlmap('d3')}); 11 | //!{istatic('js/homepage/ticker.js')} 12 | // preload the d3 library 13 | script(async). 14 | Do('d3'); 15 | 16 | -------------------------------------------------------------------------------- /templates/layout/basic.jade: -------------------------------------------------------------------------------- 1 | !!! 2 | html 3 | head 4 | meta(charset='utf-8') 5 | 6 | script. 7 | var _speedMark = new Date(); 8 | block title 9 | title #{typeof title === 'undefined' ? conf.site_name : title} 10 | link(rel='stylesheet', href=static('/css/bootstrap.css')) 11 | link(rel='stylesheet', href=static('/css/base.css')) 12 | block head_more 13 | script(src=static('/js/do.js'), data-cfg-corelib='main', data-cfg-root=static('/')) 14 | script. 15 | ASSEST_ROOT = '#{conf.assets_root}'; 16 | Do.urls(!{urlmap('main', 'lodash', 'jiathis', 'd3')}); 17 | if !conf.debug 18 | include ../common/track 19 | body 20 | block header 21 | block navbar 22 | include ../common/navbar 23 | #main.container 24 | block main 25 | block footer 26 | include ../common/footer 27 | block ending 28 | 29 | -------------------------------------------------------------------------------- /templates/layout/message.jade: -------------------------------------------------------------------------------- 1 | extend basic 2 | 3 | block title 4 | title #{typeof title != 'undefined' ? title + ' | ' : ''} #{conf.site_name} 5 | 6 | block main 7 | .container.vmiddle.fmessage 8 | block message 9 | block stack 10 | -------------------------------------------------------------------------------- /templates/mine/index.jade: -------------------------------------------------------------------------------- 1 | extend ../layout/basic 2 | 3 | include ../people/stats/book/mixins.jade 4 | 5 | block title 6 | title 7 | 我的豆瓣酱 8 | 9 | block head_more 10 | link(rel='stylesheet', href=static('/css/mine.css')) 11 | 12 | block main 13 | small.lnk-logout 14 | a(href='/logout') » 退出 15 | .row 16 | .col.col-md-3 17 | .aside 18 | include ./mods/sidebar 19 | .mod 20 | p.text-muted 觉得豆瓣酱有用? 21 | p.small 22 | a(href="/donate", target="_blank") 捐点钱让服务器更快 ! 23 | .col.col-md-9 24 | .mod 25 | h2 我关注的友邻 26 | a.btn.btn-default.btn-xs.clear-followings 刷新 27 | #followings.friends 28 | .alert.alert-warning.vmiddle 29 | 正在获取友邻列表... 30 | include ./mods/tmpl_friends_list.html 31 | small.pull-right 32 | a.text-muted(href="http://www.douban.com/people/doubanj_com/status/1153013059/", target="_blank") 有关头像上的彩色小点 33 | script. 34 | window._uid_ = '#{user.uid || user.id}'; 35 | !{istatic('js/mine/booter.js')} 36 | 37 | block navbar_form 38 | form.navbar-form.navbar-right(id="navbar-form", action="/") 39 | div.form-group 40 | input.form-control(name="q", type="text", placeholder="输入友邻个人主页地址...", title="输入豆瓣ID以查看其统计", size="46") 41 | -------------------------------------------------------------------------------- /templates/mine/mods/jump.jade: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktmud/doubanj/f7b5cc08fcb878ec655def7df7c7ff20244cdce9/templates/mine/mods/jump.jade -------------------------------------------------------------------------------- /templates/mine/mods/mixins.jade: -------------------------------------------------------------------------------- 1 | mixin avatar_list(users, extra_prop) 2 | .users-avatar.clearfix 3 | each u in users 4 | li(data-user_id=u.id) 5 | a(href="#{u.url()}") 6 | img(width="48", height="48", src="#{u.avatar}") 7 | br 8 | | #{u.name} 9 | if extra_prop 10 | br 11 | span(class="prop-#{extra_prop}") #{u[extra_prop]} 12 | -------------------------------------------------------------------------------- /templates/mine/mods/sidebar.jade: -------------------------------------------------------------------------------- 1 | include ./mixins.jade 2 | 3 | .mod 4 | h2 我的读书酱 5 | a.pull-right.btn.btn-xs.btn-default(href=user.url()) » 详细 6 | if user.stats 7 | mixin overview(user) 8 | else 9 | .vmiddle 10 | a.btn.btn-primary(href=user.url()) 点此生成报告 11 | 12 | .mod 13 | h2 与我最契合的友邻 14 | if top_click_users.length 15 | mixin avatar_list(top_click_users, 'book_score') 16 | .pull-right 17 | - topper = top_click_users[0] 18 | - share_text = '#豆瓣酱# 目前为止,和我读书品味最接近的友邻是 @' + topper.id 19 | - if (top_click_users.length > 1) share_text += ' 和 ' + top_click_users.slice(1).map(function(item) { return '@' + item.id; }).join(' ') 20 | - target_url = user.url() + 'click/' + topper.uid 21 | - share_url = "http://www.douban.com/share/service?href=" + encodeURIComponent(target_url) 22 | - share_url += "&name=" + encodeURIComponent(user.name + ' + ' + topper.name + ' 的契合指数 - 豆瓣酱') 23 | - share_url += "&comment=" + encodeURIComponent(share_text) 24 | a.btn.btn-xs(target="_blank", href="#{share_url}") 告诉他们 25 | small.text-muted 26 | *只包括已经有报表的友邻 27 | else 28 | .text-muted 29 | p 访问友邻们的页面,会自动生成你们的 30 | a(href="/about/click", target="_blank") 契合指数 31 | p 得分最高的友邻会出现在这里 32 | 33 | if top_click_users.length 34 | .mod 35 | small 36 | p 只有当你访问了某人的豆瓣酱报表,才会尝试计算你们的契合指数。计算结果也会出现在对方的记录里(即使你不是他的友邻)。 37 | p(style="margin-bottom:0") 所以如果上面出现了你不认识的人,则有可能是对方主动访问了你的页面。 38 | -------------------------------------------------------------------------------- /templates/mine/mods/tmpl_friends_list.html: -------------------------------------------------------------------------------- 1 | 9 | 25 | -------------------------------------------------------------------------------- /templates/misc/about.jade: -------------------------------------------------------------------------------- 1 | extend ../layout/basic 2 | 3 | block head_more 4 | style. 5 | h1 { font-size: 2em; margin-bottom: 1em; } 6 | #main { padding: 60px 20px; max-width: 600px; } 7 | ol { margin: 1em 2em 3em; } 8 | 9 | block title 10 | title 关于豆瓣酱(doubanj.com) 11 | 12 | block main 13 | h1 关于 doubanj.com 14 | p 这个看起来很像钓鱼网站的域名,居然一直没有人注册,我感到很奇怪。 15 | p 现在我把它拿来用了,做了这样一个网站。目前这些功能,并不是我设想的全部。 16 | p 顶着这个名字,「豆瓣酱」将只能一直围绕豆瓣网,实现各种豆瓣官方可能来不及做、不屑于做、碍于面子不肯做、做了也没多大好处的功能。 17 | h2#privacy 隐私问题 18 | p 19 | a(href="/about/privacy") » 点这里 20 | h2#tos 权责声明 21 | ol 22 | li.text-danger 本站与豆瓣官方没有任何形式的从属关系 23 | li 我们 24 | a(href="https://github.com/ktmud/doubanj") 开放源代码 25 | ,并且允许利用该源码 搭建类似网站 26 | p.text-muted.small 当然,更希望你能直接给豆瓣酱贡献你想要的功能 27 | h3 喜欢吗? 28 | p 如果觉得豆瓣酱有用,可以关注 29 | a(href="http://www.douban.com/people/69795126/") 我们的豆瓣账号 30 | |,或者 31 | a(href="/donate") 给点捐助 32 | |。 33 | p 也欢迎提出各种意见和要求,虽然大部分时候它们并不会被采纳。 34 | 35 | p 本站搭建于 36 | a(href="https://www.digitalocean.com/?refcode=a324512af88c") DigitalOcean 37 | 。如果你最近也需要便宜又好用的海外云主机,欢迎使用以下链接购买: 38 | a(href="https://www.digitalocean.com/?refcode=a324512af88c") https://www.digitalocean.com/?refcode=a324512af88c 39 | p 注册就送 $10 ,相当于可以免费使用两个月。 40 | 41 | hr 42 | p.text-muted 43 | em 44 | | 截至目前,豆瓣酱已经为 45 | span.num #{n_people} 46 | | 位豆友生成了统计报表。 47 | -------------------------------------------------------------------------------- /templates/misc/about_click.jade: -------------------------------------------------------------------------------- 1 | extend ./about 2 | 3 | block title 4 | title 什么是「契合指数」? - 豆瓣酱 5 | 6 | block main 7 | h1 「契合指数」™ 8 | p 契合指数是根据你和友邻的读书收藏,自动算出来的阅读品味契合程度[ 9 | a(href="http://www.doubanj.com/people/jinkai719/click/1299702") 示例 10 | ]。 11 | h3 为什么有这个东西? 12 | p 豆瓣已经可以帮你发掘出你可能感兴趣的书影音,为什么不能帮你发掘你可以感兴趣的人呢? 13 | h3 这个指数多少算高? 14 | p 根据现在的算法,契合指数 500 以上,可信度 80% 以上,两个人就应该可以在一起了。 15 | h3 什么叫「可信度」? 16 | p 两个人收藏的图书数量越接近,这种比较越有意义,所以可信度越高。 17 | h3 为什么会出现负分? 18 | p 如果两个人对同一本书的评星相差太大,会被扣分。 19 | h3 为什么没有音乐和电影? 20 | p 豆瓣读书开放的API更方便,而且站长觉得一个人的阅读趣味更能代表其真实品性。 21 | h3 我觉得这个结果不准! 22 | p 当然是图书收藏越多,结果越准。 23 | p 多标记你看过的书,并认真打分和撰写附注,不仅对他人有帮助,也能帮助自己养成更积极主动的阅读习惯。 24 | h3 具体的算法是怎样的? 25 | p 简而言之就是取两个人图书收藏的交叉部分,并分别计算与总收藏的占比,比例越大得分越高。 26 | p 本站代码 27 | a(target="_blank", href="https://github.com/ktmud/doubanj") 全部开源 28 | ,「契合指数」部分 29 | a(target="_blank", href="https://github.com/ktmud/doubanj/blob/master/tasks/click/book.js") 在这里 30 | 。欢迎提交 pull request 。 31 | -------------------------------------------------------------------------------- /templates/misc/about_privacy.jade: -------------------------------------------------------------------------------- 1 | extend ./about 2 | 3 | block title 4 | title 在豆瓣酱上的隐私问题 5 | 6 | block main 7 | h1 隐私问题 8 | p 豆瓣酱通过收集你在豆瓣上的 9 | strong 公开数据 10 | 为你计算统计报表。 11 | p 12 | strong.text-warning 任何人 13 | 都可以查看 14 | strong.text-warning 其他人 15 | 公开资料的统计数据。 16 | h2 「授权访问豆瓣账号」意味着什么 17 | p 通过授权豆瓣酱访问你的豆瓣账号,你可以在豆瓣酱看到自己的友邻列表。 18 | p 每次访问别人的页面时,都会自动生成你们的 19 | a(href="/about/click") 契合指数 20 | 。 21 | p 如果你们的契合指数较高, 22 | strong 即使对方没有关注你 23 | ,你也可能出现在TA的「我的」页面。 24 | p.text-muted (未来可能提供选项停用此策略) 25 | h2 为什么需要那么多权限? 26 | p 也许将来我们还能帮你统计除了豆瓣读书外的所有信息,对不对? 27 | h2 我不想我的信息以这种方式被展示 28 | p 如果你不希望自己的资料在豆瓣酱上展示,请 29 | a(href="mailto:doubanj@yjc.me") 与我们联系 30 | p.text-muted (现在还没有人提这种要求,所以如果你提了的话我还得想一下该怎么办) 31 | -------------------------------------------------------------------------------- /templates/misc/donate.jade: -------------------------------------------------------------------------------- 1 | extend ../layout/basic 2 | 3 | block title 4 | title 捐助豆瓣酱 5 | 6 | block main 7 | style. 8 | h1 { font-size: 2em; margin-bottom: 1em; } 9 | #main { padding: 60px 20px 100px; max-width: 600px; } 10 | em { margin-left: 1em; } 11 | .btn-lg { font-size: 1.8em } 12 | 13 | h1 真的很需要你的钱 14 | p 如果你觉得本站对你有帮助,欢迎用金钱回馈开发者的辛苦付出。 15 | p 本站每年的主机费用需要 ¥4000 左右,是笔不小的开支。 16 | hr 17 | p 试试用 微信扫一扫 转账:
18 | img.img-responsive(width=150, src="#{static('/pics/wechat-qrcode.jpg')}") 19 | p 金额随意,心意比较重要。 20 | hr 21 | p 或者 支付宝扫一扫 也可以:
22 | img.img-reponsive(width=150, src="#{static('/pics/alipay-qrcode.png')}") 23 | hr 24 | p 谢谢您! 25 | -------------------------------------------------------------------------------- /templates/monitor/index.jade: -------------------------------------------------------------------------------- 1 | extend ../layout/basic 2 | 3 | block main 4 | style. 5 | .monitor-overview { font-size: 20px; } 6 | .monitor-overview .mod { margin-bottom: 50px; } 7 | .users a { margin: 0 4px; display: inline-block; font-size: 14px; } 8 | h1 监控中心 9 | if err 10 | pre.alert.alert-danger 11 | !{err} 12 | hr 13 | .monitor-overview.row 14 | .col.col-lg-6 15 | h2 队列 16 | mixin queue_item('interest', '采集') 17 | mixin queue_item('compute', '统计') 18 | - queue = get_queue('click').queue 19 | .mod 20 | p 契合:#{String(queue.length)} 21 | if queue.length 22 | mixin click_queue_users(queue) 23 | .col.col-lg-6 24 | h2 统计 25 | .mod 26 | p 全部用户 27 | strong #{total} 28 | 人,已统计 29 | strong #{n_succeed} 30 | ,队列中 31 | strong #{n_ing} 32 | if timeouted.length 33 | .mod 34 | h3 超时 35 | strong #{timeouted.length} 36 | 人 37 | p.users 38 | each u in timeouted 39 | a(href="/people/#{u.uid}/", target="dbj-monitor-user") #{u.name || u.uid} 40 | .mod 41 | | 图书 42 | span.num #{n_book} 43 | | 本,收藏记录 44 | span.num #{n_book_interest} 45 | | 条 46 | p csrf to use: 47 | pre 48 | | #{_csrf} 49 | 50 | 51 | mixin queue_item(queue, title) 52 | - queue = get_queue(queue).queue 53 | .mod 54 | p #{title}:#{String(queue.length)} 55 | if queue.length 56 | mixin queue_users(queue) 57 | 58 | mixin queue_users(queue, param_name) 59 | - param_name = param_name || 'user' 60 | p.users 61 | each uid in queue.map(function(arg) { return arg[1][param_name]; }).slice(0, 40) 62 | if (typeof uid === 'object') 63 | uid = uid.uid 64 | a(href="/people/#{uid}/", target="dbj-monitor-user") #{uid} 65 |   66 | 67 | mixin click_queue_users(queue) 68 | p.users 69 | each uid in queue.map(function(arg) { return arg[1].users; }).slice(0, 40) 70 | - uid = uid.map(function(item) { if (typeof item === 'object') { item = item.uid } return item; }) 71 | a(href="/people/#{uid[0]}/click/#{uid[1]}", target="dbj-monitor-user") #{uid[0]} + #{uid[1]} 72 |   73 | -------------------------------------------------------------------------------- /templates/people/cases/failed.jade: -------------------------------------------------------------------------------- 1 | .alert.alert-danger 2 | p 准备数据时出错了,这可能是程序bug,请等待我们的工程师修复 3 | 4 | if people.stats_status == 'ing' 5 | p.text-muted 数据同步已成功,正在重新计算... 已重试 #{people.stats_fail} 次 6 | else if people.stats_fail > 1 7 | p.text-muted 已重试 #{people.stats_fail} 次,仍然失败了 8 | else 9 | p.text-muted 你可以在当前 url 后添加 ?recount 重新尝试计算 10 | 11 | // compute failed 5 times more 12 | if people.stats_fail > 1 13 | hr 14 | include ../mods/resync 15 | -------------------------------------------------------------------------------- /templates/people/cases/never.jade: -------------------------------------------------------------------------------- 1 | form.vmiddle(method="post", action="/queue") 2 | input(type="hidden", name="_csrf", value=_csrf) 3 | input(type="hidden", name="uid", value=uid) 4 | //- p 5 | //- button.btn.btn-primary 开始为 #{name} 制造美味豆瓣酱 6 | if req.user 7 | hr 8 | if req.user == people 9 | small.text-muted 分析你的阅读历史 10 | else 11 | small.text-muted 为TA制造豆瓣酱,查看你们的契合指数 12 | -------------------------------------------------------------------------------- /templates/people/cases/sync_failed.jade: -------------------------------------------------------------------------------- 1 | - if (conf.debug || (new Date() - people.last_synced > 120000)) 2 | p.alert.alert-warning 上次同步数据时出错了,试试重新同步吧 3 | include ../mods/resync 4 | else 5 | .alert.alert-danger 6 | p 和豆瓣同步数据时出错了,可能程序有点问题,过几天再试吧 7 | -------------------------------------------------------------------------------- /templates/people/cases/wait.jade: -------------------------------------------------------------------------------- 1 | - need_resync = people.needResync() 2 | .vmiddle 3 | if need_resync 4 | .alert.alert-danger 上一次同步数据似乎出错了,重试一下吧 5 | else 6 | include ../mods/progress 7 | p#remains.text-muted 8 | 正在准备数据,请耐心等待 9 | 10 | if need_resync 11 | hr 12 | include ../mods/resync 13 | 14 | -------------------------------------------------------------------------------- /templates/people/cases/zero.jade: -------------------------------------------------------------------------------- 1 | p 天哪,这位 #{people.name} 居然一本书都没有读过! 2 | hr 3 | - if (new Date() - people.last_synced > 120000) 4 | include ../mods/resync 5 | - else 6 | // cannot requeue in two minutes 7 | p.text-muted 8 | if req.user === people 9 | 你需要 10 | a(href="http://book.douban.com/") 去收藏几本书 11 | | 吗? 12 | 两分钟后才可以重试 13 | -------------------------------------------------------------------------------- /templates/people/click/index.jade: -------------------------------------------------------------------------------- 1 | extend ../../layout/basic 2 | 3 | block head_more 4 | link(rel='stylesheet', href=static('/css/mine.css')) 5 | 6 | block title 7 | title #{people._name} 与 #{other._name} 的契合指数 8 | 9 | block main 10 | .row 11 | .col.col-md-9.click-results 12 | block breadcrumb 13 | ul.breadcrumb 14 | li 15 | a(href=people.url()) #{name}的豆瓣酱 16 | li.active 17 | | 与#{other._name} 18 | block h1 19 | .pull-right 20 | include ../mods/share.html 21 | h1 22 | a(href="#{people.url()}") #{people._name} 23 | |  +  24 | a(href="#{other.url()}") #{other._name} 25 | block switch 26 | |   27 | a.btn.btn-default.btn-xs(href="#{other.url()}click/#{people.uid}") 交换视角 28 | block left 29 | if clicks.commented.length 30 | commented = central.utils.shuffle(clicks.commented.slice(0, 10)).slice(0,3) 31 | h2 对同一本书的不同评语 32 | - if (clicks.commented.length > commented.length) 33 | small ( 34 | a(href="#{people.click_url(other)}/quote") 全部#{clicks.commented.length} 35 | ) 36 | include ./mods/quote.jade 37 | hr 38 | include ./mods/done.jade 39 | .col.col-md-3 40 | block sidebar 41 | include ./mods/sidebar.jade 42 | -------------------------------------------------------------------------------- /templates/people/click/loading.jade: -------------------------------------------------------------------------------- 1 | extend ./index 2 | 3 | block left 4 | .vmiddle#click-loading 5 | .click-progress.progress.progress-striped.active 6 | .progress-bar(style="width:1%") 7 | small.text-muted 正在生成#{people._name}和#{other._name}的契合指数 8 | script. 9 | !{istatic('js/mine/click.js')} 10 | Do.loadClick('#{people.uid}', '#{other.uid}', '#click-loading', function() { 11 | setTimeout(function() { 12 | location.reload(); 13 | }, 500); 14 | }); 15 | 16 | block sidebar 17 | block switch 18 | 19 | -------------------------------------------------------------------------------- /templates/people/click/mods/done.jade: -------------------------------------------------------------------------------- 1 | include ./mixins.jade 2 | 3 | h2 都读过的书 4 | if clicks.ratios.done[0] 5 | small (共#{clicks.done.length}本, 占#{people._name}全部读过的 #{(clicks.ratios.done[0] * 100).toFixed(2)}%) 6 | mixin book_list(clicks.done.slice(0, 6), null, mixed_rating_stars_mixin) 7 | hr 8 | h2 都想读的书 9 | if clicks.ratios.wish[0] 10 | small (共#{clicks.wish.length}本, 占#{people._name}全部读过的 #{(clicks.ratios.wish[0] * 100).toFixed(2)}%) 11 | mixin book_list(clicks.wish.slice(0, 6)) 12 | hr 13 | h2 都喜欢的书 14 | small (四星以上 15 | if clicks.ratios.love[0] 16 | | ,共#{clicks.love.length}本, 占#{people._name}全部喜欢的书之 #{(clicks.ratios.love[0] * 100).toFixed(2)}% 17 | | ) 18 | mixin book_list(clicks.love.slice(0, 6), null, mixed_rating_stars_mixin) 19 | hr 20 | h2 都不喜欢的书 21 | small (两星以下 22 | if clicks.ratios.hate[0] 23 | ,共#{clicks.hate.length}本, 占#{people._name}全部不喜欢的书之 #{(clicks.ratios.hate[0] * 100).toFixed(2)}% 24 | | ) 25 | mixin book_list(clicks.hate.slice(0, 6), null, mixed_rating_stars_mixin) 26 | hr 27 | h2 #{people._name}读过的#{other._name}想读的书 28 | if clicks.done_wish.length && other.book_stats.wish 29 | - n = clicks.done_wish.length 30 | small (共#{n}本, 占#{other._name}全部想读的 #{(n / other.book_stats.wish.total).toFixed(3)}%) 31 | mixin book_list(clicks.done_wish.slice(0, 6)) 32 | hr 33 | h2 #{other._name}读过的#{people._name}想读的书 34 | if clicks.wish_done.length && people.book_stats.wish 35 | - n = clicks.wish_done.length 36 | small (共#{n}本, 占#{people._name}全部想读的 #{(n / people.book_stats.wish.total).toFixed(3)}%) 37 | mixin book_list(clicks.wish_done.slice(0, 6)) 38 | hr 39 | 40 | if clicks.ratios.love_hate[0] 41 | h2 #{people._name}喜欢#{other._name}却不喜欢的书 42 | - n = clicks.love_hate.length 43 | small (共#{n}本, 占#{people._name}全部喜欢的#{(clicks.ratios.love_hate[0] * 100).toFixed(2)}%) 44 | mixin book_list(clicks.love_hate.slice(0, 6), null, mixed_rating_stars_mixin) 45 | hr 46 | 47 | if clicks.ratios.hate_love[0] 48 | h2 #{people._name}不喜欢#{other._name}却喜欢的书 49 | - n = clicks.hate_love.length 50 | small (共#{n}本, 占#{people._name}全部不喜欢的#{(clicks.ratios.hate_love[0] * 100).toFixed(2)}%) 51 | mixin book_list(clicks.hate_love.slice(0, 6), null, mixed_rating_stars_mixin) 52 | hr 53 | -------------------------------------------------------------------------------- /templates/people/click/mods/mixins.jade: -------------------------------------------------------------------------------- 1 | include ../../mixins/interest 2 | 3 | mixin mixed_ratings(subject_id) 4 | each u in [people, other] 5 | item = u._interest_by_subject_id[subject_id] 6 | - if (item && item.rating) 7 | .rating A: 8 | mixin stars(item.rating) 9 | - else 10 | .rating B: 未评价 11 | 12 | mixin rating_text(user, subject_id) 13 | - item = user._interest_by_subject_id[subject_id] 14 | if !item 15 | - return 16 | if item.rating 17 | | #{user._name}的评价: 18 | mixin stars(item.rating) 19 | else 20 | | #{user._name}未评价 21 | 22 | mixin mixed_rating_text(subject_id) 23 | - item1 = people._interest_by_subject_id[subject_id] 24 | - item2 = other._interest_by_subject_id[subject_id] 25 | - item = item1 && item1.rating && item1 || item2 26 | if !item 27 | - return 28 | div.mixed-ratings 29 | - if (item1 && item2 && item1.rated && item2.rated) 30 | - if (item1.rated === item2.rated) 31 | p #{people._name}和#{other._name}都给了#{item.rated}星 32 | - else 33 | - if (item1 && item1.rated) 34 | p #{people._name}给了#{item1.rated}星 35 | - if (item2 && item2.rated) 36 | p #{other._name}给了#{item2.rated}星 37 | 38 | mixin rate_stars(i) 39 | if i 40 | mixin stars(i) 41 | else 42 | small.text-muted (暂未评价) 43 | 44 | mixin mixed_rating_stars(subject_id) 45 | - item1 = people._interest_by_subject_id[subject_id] 46 | - item2 = other._interest_by_subject_id[subject_id] 47 | div.mixed-ratings.mixed-stars 48 | mixin rate_stars(item1.rated) 49 | .splitter v.s. 50 | mixin rate_stars(item2.rated) 51 | 52 | mixin book_list(ids, head, tail) 53 | if (!ids || !ids.length) 54 | p.no-result 暂时没有诶 ... 55 | - return 56 | ol.rbox-list.interest-rboxes 57 | each i in ids 58 | if !(i in all_books) 59 | - continue 60 | - subject = all_books[i] 61 | li 62 | if head 63 | .rbox-supple 64 | !{head(i)} 65 | a(href="http://book.douban.com/subject/#{subject.id}/", target="db-#{subject.type}") 66 | if subject.images 67 | .pic 68 | img(src=subject.images.medium) 69 | p.title #{subject.title} 70 | if subject.author 71 | p.meta.authors !{subject.author.join(', ')} 72 | if subject.publisher 73 | p.meta.publisher #{subject.publisher} 74 | if tail 75 | .rbox-supple 76 | !{tail(i)} 77 | -------------------------------------------------------------------------------- /templates/people/click/mods/quote.jade: -------------------------------------------------------------------------------- 1 | include ../../stats/book/mixins.jade 2 | 3 | mixin comment_item(i) 4 | small.pull-right.text-muted #{strftime('%Y-%m-%d', i.updated)} #{i.status_cn()} 5 | mixin stars(i.rating) 6 | if i.subject 7 | .subject-image 8 | mixin subject_image(i.subject) 9 | mixin quote_comment(i.comment || '') 10 | 11 | mixin user_th(user) 12 | th #{user._name} 13 | a.btn.btn-default.btn-xs.pull-right(href="#{user.url()}quote") 全部评语 » 14 | 15 | table.table.table-bordered.comments-list 16 | tr.heading 17 | th   18 | mixin user_th(people) 19 | mixin user_th(other) 20 | each sid in commented 21 | - subject = all_books[sid] 22 | if !subject 23 | - continue 24 | - a = people._interest_by_subject_id[sid] 25 | - b = other._interest_by_subject_id[sid] 26 | tr 27 | th.subject 28 | mixin subject_image(subject) 29 | a(href="http://book.douban.com/subject/#{sid}/") #{subject.title} 30 | td 31 | mixin comment_item(a, true) 32 | td 33 | mixin comment_item(b, true) 34 | -------------------------------------------------------------------------------- /templates/people/click/mods/sidebar.jade: -------------------------------------------------------------------------------- 1 | include ../../mixins/interest.jade 2 | 3 | .well#aside-click 4 | table.click-indexes 5 | tr 6 | td 7 | h3 契合指数 8 | .click-number #{String(clicks.score || 0)} 9 | td.click-grade #{click_grade} 10 | p.reliability 可信度: 11 | strong #{clicks.reliability}% 12 | a(href="/about/click", title="查看什么叫「可信度」") [?] 13 | if (locals.mutual_keywords && mutual_keywords.length) 14 | h2 共同阅读关键字 15 | mixin tagcloud(mutual_keywords) 16 | hr 17 | if !req.user 18 | p.text-muted > 19 | a(href="/mine") 我也想要这样的报表 20 | else if req.user != people 21 | p.text-muted > 22 | a(href="#{req.user.click_url(people)}") 看看我与#{people.name} 23 | p.text-muted > 24 | a(href="#{req.user.click_url(other)}") 看看我与#{other.name} 25 | else 26 | p.text-muted > 27 | a(href="/mine") 返回我的友邻 28 | -------------------------------------------------------------------------------- /templates/people/click/no_other.jade: -------------------------------------------------------------------------------- 1 | extend ./index 2 | 3 | block title 4 | title #{people._name} 与 #{other._name} 的契合指数 5 | 6 | block main 7 | .vmiddle 8 | .alert.alert-danger 9 | p 用户 #{other._name} 并不存在 10 | hr 11 | small 12 | a(href="#{people.url()}") 回#{people.name}的豆瓣酱 13 | 14 | block sidebar 15 | block switch 16 | -------------------------------------------------------------------------------- /templates/people/click/not_ready.jade: -------------------------------------------------------------------------------- 1 | extend ./index 2 | 3 | block left 4 | .vmiddle 5 | .alert.alert-danger 6 | p 要查看契合指数,需要两人的统计数据都已准备好 7 | hr 8 | small.text-muted 9 | a(href="#{people.url()}") 去#{people._name}的页面 10 | |    |    11 | a(href="#{other.url()}") 去#{other._name}的页面 12 | 13 | block sidebar 14 | block switch 15 | -------------------------------------------------------------------------------- /templates/people/click/quote.jade: -------------------------------------------------------------------------------- 1 | extend ./index 2 | 3 | block title 4 | title #{people._name} 与 #{other._name} 对一本书的不同评语 5 | 6 | block left 7 | if !commented.length 8 | .well 这两个人暂时没有对同一本书发表过看法 9 | else 10 | include ./mods/quote.jade 11 | 12 | block breadcrumb 13 | ul.breadcrumb 14 | li 15 | a(href=people.url()) #{name}的豆瓣酱 16 | li 17 | a(href=people.click_url(other)) 与#{other._name} 18 | li.active 19 | | 阅读体悟 20 | 21 | block h1 22 | .pull-right 23 | include ../mods/share.html 24 | h1 #{people._name} v.s. #{other._name} 25 | -------------------------------------------------------------------------------- /templates/people/failed.jade: -------------------------------------------------------------------------------- 1 | extend ../500 2 | 3 | block message 4 | if (err && err.message === 'pulling') 5 | h1 OH, wait.... 6 | p 正在尝试获取用户信息,耐心一点啦 7 | if err.n 8 | p.text-muted 9 | small 你前面还有#{err.n}位用户 10 | else if (err && err.statusCode != 404 && err != 404) 11 | h1 OH, NO! 12 | p 获取用户出错了 13 | if people 14 | hr 15 | include ./mods/resync.jade 16 | else 17 | h1 OH, NO! 18 | p 找不到此用户 19 | hr 20 | p.text-muted 21 | | 请注意,「豆瓣ID」并不是你的登录账号,也不是你的名号,
22 | | 而是个人主页 www.douban.com/people/... 后的那一串数字或字符
23 | small 24 | a(href="/") 回首页 25 | -------------------------------------------------------------------------------- /templates/people/index.jade: -------------------------------------------------------------------------------- 1 | extend ../layout/basic 2 | 3 | block head_more 4 | link(rel='stylesheet', href=static('/css/mine.css')) 5 | 6 | block main 7 | .row 8 | .col.col-md-9 9 | block article 10 | h1 #{name} 的豆瓣酱 11 | if people.invalid && !people.last_synced 12 | p.vmiddle.alert.alert-danger 获取用户信息不小心失败了,休息一会再重试看看吧 13 | else if !people.last_synced_status 14 | // 根本没有同步过 15 | include ./cases/never 16 | else if people.isIng() && (!people.stats || !people.book_stats.total) 17 | .vmiddle 18 | include ./cases/wait 19 | else if people.isEmpty('book') 20 | .vmiddle 21 | include ./cases/zero 22 | else if people.stats 23 | //- console.log(people.stats) 24 | // is OK to show stats 25 | include ./mods/stats 26 | else if people.stats_fail 27 | .vmiddle 28 | include ./cases/failed 29 | else if people.syncFailed() 30 | .vmiddle 31 | include ./cases/sync_failed 32 | else if people.syncTimeout() 33 | .vmiddle 34 | p.alert.alert-danger 抱歉,同步数据超时了,重试看看吧 35 | hr 36 | include ./mods/resync 37 | else 38 | .vmiddle 39 | include ./cases/wait 40 | .col.col-md-3 41 | block sidebar 42 | if (!req.url.split('/').pop() && req.user && req.user !== people && people.book_stats) 43 | include ./mods/click 44 | if people.created 45 | include ./mods/intro 46 | -------------------------------------------------------------------------------- /templates/people/interests.jade: -------------------------------------------------------------------------------- 1 | extend ../layout/basic 2 | include ./mixins/interest 3 | 4 | block main 5 | if !interests 6 | p.alert.alert-warning 此用户的数据还未准备好,请过一会儿再回来 7 | else 8 | .people-info 9 | h1 用户 #{people.screen_name || people.uid} 的#{ns_name}收藏 10 | if people.last_synced_status === 'ing' 11 | span.alert.badge-syncing 正在同步 12 | if interests.book 13 | mixin book 14 | if interests.movie 15 | mixin movie 16 | if interests.music 17 | mixin music 18 | 19 | mixin book 20 | .alert.alert-success 21 | p 共 #{people.book_n} 条图书记录,已同步 #{people.book_synced_n} 条,上次同步于  22 | time #{strftime(FULL_TIME, people.book_last_synced)} 23 | mixin interests_list(interests.book) 24 | 25 | mixin movie 26 | 27 | mixin music 28 | -------------------------------------------------------------------------------- /templates/people/mixins/enable_links.jade: -------------------------------------------------------------------------------- 1 | mixin enable_links(text, limit) 2 | - limit = limit || 42 3 | - reg_url = /(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!10(?:\.\d{1,3}){3})(?!127(?:\.\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!192\.168(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z0-9\u00a1-\uffff]+-?)*[a-z0-9\u00a1-\uffff]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?/ig 4 | - reg_lt = //g 6 | - text = text.replace(reg_gt, '>') 7 | - text = text.replace(reg_lt, '<') 8 | - text = text.replace(reg_url, function(p0) { return ' ' + simple_trunc(p0, limit) + ' '; }) 9 | - reg_nl = /\n/g 10 | - text = text.replace(reg_nl, '
') 11 | | !{text} 12 | 13 | 14 | -------------------------------------------------------------------------------- /templates/people/mixins/interest.jade: -------------------------------------------------------------------------------- 1 | mixin tagcloud(tags, ns) 2 | ns = ns || locals.ns || 'book' 3 | .tagcloud 4 | each tag in tags 5 | if tag && tag._id 6 | a.tag-item(href='/tag/' + encodeURIComponent(tag._id), target="_blank") 7 | strong #{tag._id} 8 | |   9 | span.text-muted (#{tag.count}#{tag.count_b ? ':' + tag.count_b : ''}) 10 | 11 | - ns_cats = { 'book': '1001' } 12 | mixin searchcloud(tags, ns) 13 | ns = ns || locals.ns 14 | cat = ns_cats[ns] 15 | .tagcloud 16 | each tag in tags 17 | if tag && tag._id 18 | span.tag-item 19 | a(href="http://" + ns + '.douban.com/subject_search?cat=' + cat + '&search_text=' + encodeURIComponent(tag._id), 20 | target="_blank") #{tag._id} 21 | span.text-muted (#{tag.count}) 22 | 23 | - make_stars = function(star) { if (!star || !star.value) return ''; var n = star.max || 5, s = ''; star = star.value; while (n > 0) { star--; n--; s += star >= 0 ? '✭' : '✩' } return s; } 24 | 25 | mixin stars(star, only_text) 26 | if star && star.value 27 | - star = star.value 28 | if !star 29 | - return 30 | - var n = star.max || 5, s = ''; 31 | while n > 0 32 | - star-- 33 | - n -- 34 | - s += star >= 0 ? '✭' : '✩' 35 | if only_text 36 | !{s} 37 | - return 38 | span.stars !{s} 39 | 40 | mixin interest_item(i, trimto) 41 | if !i 42 | - return 43 | trimto = trimto || 14 44 | span.label.label-default.label-status #{i.status_cn()} 45 |   46 | if i.subject 47 | a(href="http://#{i.subject_ns()}.douban.com/subject/#{i.subject_id}/", 48 | target="_blank", 49 | title="#{i.subject.title}#{i.subject.author ? ' - ' + i.subject.author.join(', ') : ''}") 50 | | #{trunc(i.subject.title, trimto)} 51 | else 52 | a(href="http://#{i.subject_ns()}.douban.com/subject/#{i.subject_id}/", target="_blank") 53 | | #{i.subject_id} 54 | 55 | 56 | mixin interests_list(items) 57 | ol.rbox-list.interest-rboxes 58 | each i in items 59 | li 60 | a(href="http://#{i.subject_ns()}.douban.com/subject/#{i.subject_id}/", target="db-#{i.subject_type}") 61 | div.status #{i.status_cn()} 62 | mixin stars(i.rating) 63 | if i.subject 64 | if i.subject.images 65 | .pic 66 | img(src=i.subject.images.medium) 67 | p.title #{i.subject.title} 68 | if i.subject.author 69 | p.meta.authors !{i.subject.author.join(', ')} 70 | if i.subject.publisher 71 | p.meta.publisher #{i.subject.publisher} 72 | else 73 | | #{i.subject_type}:#{i.subject_id} 74 | -------------------------------------------------------------------------------- /templates/people/mixins/paginator.jade: -------------------------------------------------------------------------------- 1 | mixin paginator(start, total, perpage, wing) 2 | perpage = perpage || 20 3 | wing = wing || 3 4 | - link = function(s) { return '?start=' + s; } 5 | - prev = start - perpage 6 | - next = start + perpage 7 | - p_current = Math.ceil((start + perpage) / perpage) 8 | - p_min = p_current - wing 9 | - p_max = p_current + wing 10 | - p_last = Math.ceil(total / perpage) 11 | - pages = [] 12 | - i = p_current, n = start 13 | - while (i > 0 && i >= p_min) 14 | - cls = (i == p_current) ? 'active' : null; 15 | - pages.unshift({ cls: cls, text: i, span: cls === 'active', href: link(n) }) 16 | - if (i === p_min && i > 1) 17 | - pages.unshift({ cls: 'disabled', span: true, text: '...', }) 18 | - i-- 19 | - n = Math.max(n - perpage, 0) 20 | 21 | - i = p_current, n = start 22 | - while (i < p_last && i < p_max) 23 | - i++ 24 | - n += perpage 25 | - pages.push({ cls: null, span: false, text: i, href: link(n) }) 26 | - if (i === p_max && i < p_last) 27 | - pages.push({ cls: 'disabled', span: true, text: '...', }) 28 | 29 | ul.pagination 30 | - if (prev >= 0) 31 | li 32 | a(href="#{link(prev)}", title="上一页") « 33 | - else 34 | li.diasbled 35 | span « 36 | each p in pages 37 | li(class=p.cls) 38 | if p.span 39 | span #{p.text} 40 | else 41 | a(href=p.href, title="第#{p.text}页") #{p.text} 42 | - if (next < total) 43 | li 44 | a(href="#{link(next)}", title="下一页") » 45 | - else 46 | li.diasbled 47 | span » 48 | 49 | -------------------------------------------------------------------------------- /templates/people/mods/click.jade: -------------------------------------------------------------------------------- 1 | #aside-click.well 2 | if req.user._click 3 | h4 你和#{people.name}的契合指数 4 | .click-number 5 | strong #{String(req.user._click.score || 0)} 6 | a.btn.btn-small.btn-primary(href="#{req.user.url()}click/#{people.uid}") 查看详细 7 | else 8 | h4 你和#{people.name} 9 | .click-results 10 | .click-progress.progress.progress-striped.active 11 | .progress-bar(style="width:5%") 12 | small.text-muted 正在生成你们的契合指数 13 | include ./tmpl_click.html 14 | script. 15 | !{istatic('js/mine/click.js')} 16 | Do.loadClick('#{req.user.uid}', '#{people.uid}', '#aside-click'); 17 | -------------------------------------------------------------------------------- /templates/people/mods/intro.jade: -------------------------------------------------------------------------------- 1 | include ../mixins/enable_links.jade 2 | 3 | .people-intro 4 | h2 谁是 #{name} ? 5 | p.basic 6 | a(href=people.db_url(), target="_blank") #{name} 7 | if people.loc_name 8 | |,住在 9 | if people.loc_id 10 | a(href='http://www.douban.com/location/#{people.loc_id}/', target="_blank") #{people.loc_name} 11 | else 12 | | #{people.loc_name} 13 | | ,#{strftime(FULL_TIME_1, people.created)}加入豆瓣。至今已经 14 | span.elapse #{time_elapse(people.created)}。 15 | if people.desc 16 | hr 17 | .clearfix 18 | img.pull-right.img-rounded(src=people.avatar, height="48") 19 | p.desc 20 | mixin enable_links(people.desc) 21 | -------------------------------------------------------------------------------- /templates/people/mods/progress.jade: -------------------------------------------------------------------------------- 1 | - progress_colors = locals.progress_colors || ['success', 'warning'] 2 | #progress.progress.progress-striped.active 3 | each p, i in people.progresses() 4 | - color = progress_colors[i] 5 | div(class="progress-bar progress-bar-#{color}", style="width:#{p}%") 6 | if people.isIng() 7 | script. 8 | window._uid_ = '#{people.uid}'; 9 | !{istatic('js/people/progress.js')} 10 | -------------------------------------------------------------------------------- /templates/people/mods/restats.jade: -------------------------------------------------------------------------------- 1 | a(href="#{people.url}?recount") 重新生成报表 2 | -------------------------------------------------------------------------------- /templates/people/mods/resync.jade: -------------------------------------------------------------------------------- 1 | form(action="/queue", method="post") 2 | input(type="hidden", name="_csrf", value=_csrf) 3 | input(type="hidden", name="uid", value=people.uid) 4 | input(type="hidden", name="force", value=1) 5 | input(type="hidden", name="fresh", value=1) 6 | button.btn.btn-sm.btn-danger(type="submit") 重新从豆瓣同步数据 7 | -------------------------------------------------------------------------------- /templates/people/mods/share.html: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | 5 | 6 |
7 | 15 | 16 | -------------------------------------------------------------------------------- /templates/people/mods/stats.jade: -------------------------------------------------------------------------------- 1 | - last_statsed = people.last_statsed || people.stats.book 2 | if !people.isIng() 3 | p.alert.alert-success 4 | | 上次同步于  5 | time #{strftime(FULL_TIME, people.book_last_synced)} 6 | | ,报表生成于 7 | time #{strftime(FULL_TIME, people.stats.book)} 8 | else if people.stats_fail 9 | .alert.alert-danger(style="padding: 10px;") 10 | 计算最新报表时出错了,可能是服务器故障,也可能是你的数据太变态了 11 | a.btn.btn-xs.btn-danger.pull-right(href="#{people.url()}?recount") 重试 12 | else 13 | .alert.alert-info(style="padding: 10px;") 14 | .row 15 | .col.col-md-8 16 | if people.isSyncing() 17 | 正在同步最新数据 18 | | ,以下是基于同步于 #{strftime(FULL_TIME, people.last_synced)} 的数据生成的结果 19 | else 20 | 正在重新生成报表 21 | | ,以下是 #{strftime(FULL_TIME, last_statsed || people.last_synced)} 生成的结果 22 | .col.col-md-3 23 | include ../mods/progress 24 | .col.col-md-1 25 | a.btn.btn-default.btn-xs.pull-right(href="?recount") 刷新 26 | 27 | section 28 | include ../stats/book 29 | .pull-left 30 | - timediff = 2550000 - (new Date() - people.last_synced) 31 | - if (timediff <= 0 || conf.debug) 32 | include ../mods/sync 33 | - else 34 | small.text-muted #{chinese_period(timediff, true)}后可重新同步 35 | 36 | .vmiddle 37 | p.text-muted.notice-more 38 | ... 更多功能正在紧张开发中,欢迎随时回来看看 39 | 40 | include ./stats_js 41 | -------------------------------------------------------------------------------- /templates/people/mods/stats_js.jade: -------------------------------------------------------------------------------- 1 | script. 2 | // modules will be used in this page 3 | Do.urls(!{urlmap('people/booter', 'd3', 'crossfilter', 'chart/all')}) 4 | Do('people/booter'); 5 | -------------------------------------------------------------------------------- /templates/people/mods/sync.jade: -------------------------------------------------------------------------------- 1 | form.inline-form(action="/queue", method="post") 2 | input(type="hidden", name="_csrf", value=_csrf) 3 | input(type="hidden", name="uid", value=people.uid) 4 | button.btn.btn-xs(type="submit") 从豆瓣同步最新数据 5 | -------------------------------------------------------------------------------- /templates/people/mods/tmpl_click.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /templates/people/quote.jade: -------------------------------------------------------------------------------- 1 | extend ./index 2 | 3 | include ./mixins/paginator 4 | include ./stats/book/mixins 5 | 6 | block article 7 | ul.breadcrumb 8 | li 9 | a(href=people.url()) #{name}的豆瓣酱 10 | li 11 | a(href="#{people.url()}read") 读过的书 12 | li.active 13 | | 阅读体悟 14 | if !req.query.start 15 | .pull-right 16 | include ./mods/share.html 17 | h1 #{title} 18 | - ns = 'book' 19 | .mod 20 | if commented_total 21 | ol.comments-list 22 | each i in most_commented 23 | if i.comment 24 | li(id="comment-#{i._id}") 25 | mixin comment_item(i) 26 | .pager-center 27 | mixin paginator(req.query.start, commented_total, perpage, 4) 28 | span.text-muted  #{page_start + 1}-#{page_start + most_commented.length}/#{commented_total} 29 | else 30 | p 还没有撰写过任何阅读体悟哦 31 | hr 32 | .mod(style="text-align:center") 33 | a.pull-right.btn.btn-success.btn-small(href="http://#{ns}.douban.com/people/#{uid}/reviews", target="db-review") 去看#{people.name}的长评 ⇗ 34 | if req.user && req.user != people 35 | a.btn.btn-default.btn-small(href="#{user.click_url(people)}/quote", target="dbj-click") 看看我和#{people.name}对同一本书的不同评价 36 | a.pull-left.btn.btn-small.btn-default(href="#{people.url()}read") < 返回 #{people.name}读过的书 37 | -------------------------------------------------------------------------------- /templates/people/stats/book.jade: -------------------------------------------------------------------------------- 1 | include ../mixins/interest 2 | 3 | - s = people[istatus.ns + '_stats'] 4 | - coll = s[istatus.status] 5 | 6 | .pull-right 7 | include ../mods/share.html 8 | h2 #{people.name} 的读书酱 9 | a.btn.btn-success.btn-xs(href=people.db_url('book'), target="db-profile") 读书主页 ⇗ 10 | hr 11 | include ./book/overview 12 | 13 | hr 14 | - locals.section_title = '偏好' 15 | include ./book/tags 16 | hr 17 | - locals.section_title = '阅读关键词' 18 | include ./book/keywords 19 | hr 20 | include ./book/history 21 | hr 22 | include ./book/tops 23 | 24 | include ./book/favs 25 | 26 | hr 27 | a.pull-right.btn.btn-small.btn-info(href=people.url() + 'read') > 只看读过的书 28 | -------------------------------------------------------------------------------- /templates/people/stats/book/done.jade: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktmud/doubanj/f7b5cc08fcb878ec655def7df7c7ff20244cdce9/templates/people/stats/book/done.jade -------------------------------------------------------------------------------- /templates/people/stats/book/favs.jade: -------------------------------------------------------------------------------- 1 | #favorites.row 2 | .col.col-lg-12 3 | h4 个人评价最高 4 | a.anchor(href="#favorites") # 5 | mixin interests_list(highest_ratings) 6 | -------------------------------------------------------------------------------- /templates/people/stats/book/history.jade: -------------------------------------------------------------------------------- 1 | #history.row.mod 2 | - statuses = [istatus.status] 3 | - legend = istatus.status === 'all' 4 | .col.col-lg-12 5 | if legend 6 | small.pull-right.bar-style-toggler 7 | label 8 | input(type="radio", name="bar-style", value="stacked", checked) 9 | 堆叠 10 | label 11 | input(type="radio", name="bar-style", value="grouped") 12 | 分组 13 | h3 阅读编年史 14 | a.anchor(href="#history") # 15 | .col.col-lg-12 16 | .chart 17 | ul.nav.nav-pills.nav-pills-small.chart-filter 18 | li.active 19 | a(href="time:all") 全部 20 | li 21 | a(href="time:-2y") 最近两年 22 | li 23 | a(href="time:-1y") 最近一年 24 | li 25 | a(href="time:last_year") 去年 26 | .chart-bar#d-by_updated(data-legend=legend, data-bar=people.book_csv('by_updated_month', statuses), data-y="本", data-periodic="true") 27 | .caption 每月阅读量 28 | .col.col-lg-12 29 | .chart 30 | .chart-bar#d-by_pubdate(data-legend=legend, data-bar=people.book_csv('by_pubdate_decades', statuses), data-y="本") 31 | .caption 收藏图书的出版年代 32 | -------------------------------------------------------------------------------- /templates/people/stats/book/hots.jade: -------------------------------------------------------------------------------- 1 | #hot-tags.row.mod 2 | - locals.ns = 'book' 3 | .col.col-md-3 4 | h4 个人标注 5 | - top_tags = s.interest.top_tags 6 | if top_tags && top_tags.length 7 | mixin tagcloud(top_tags) 8 | else 9 | p.text-muted 懒到没有打过标签 10 | .col.col-md-5 11 | h4 全部热门 12 | mixin tagcloud(coll.top_tags) 13 | .col.col-md-4 14 | h4 作者 15 | mixin searchcloud(coll.top_author.slice(0,5)) 16 | h4 译者 17 | mixin searchcloud(coll.top_translator.slice(0,8)) 18 | .col.col-md-12 19 | h4 出版社 20 | mixin searchcloud(coll.top_publisher.slice(0,12)) 21 | -------------------------------------------------------------------------------- /templates/people/stats/book/keywords.jade: -------------------------------------------------------------------------------- 1 | #keywords.row.mod 2 | .col.col-lg-12 3 | h3 #{locals.section_title || '关键词'} 4 | a.anchor(href="#keywords") # 5 | .chart 6 | - tree = JSON.stringify(people.book_keywords(istatus.status)) 7 | #d-keywords.chart-treemap(data-ns="book", data-tree=tree) 8 | -------------------------------------------------------------------------------- /templates/people/stats/book/mixins.jade: -------------------------------------------------------------------------------- 1 | include ../../mixins/enable_links.jade 2 | 3 | mixin comment_item(i) 4 | small.text-muted.datetime.pull-right #{strftime('%Y-%m-%d', i.updated)} 5 | p #{i.status === 'done' ? '评' : i.status_cn()}《 6 | mixin subject_link(i) 7 | |》 8 | mixin stars(i.rating) 9 | if i.subject 10 | .subject-image 11 | mixin subject_image(i.subject) 12 | mixin quote_comment(i.comment) 13 | 14 | mixin quote_comment(comment) 15 | blockquote 16 | - c = comment.split('\n\n') 17 | each p in c 18 | p 19 | mixin enable_links(p.replace('\n', '  '), 60) 20 | 21 | mixin subject_link(i) 22 | a(href="http://#{i.subject_ns()}.douban.com/subject/#{i.subject_id}/", target="db-#{i.subject_type}") #{i.subject && i.subject.title || i.subject_id} 23 | 24 | mixin subject_image(subject) 25 | a(href="#{subject.db_url()}", target="db-#{subject.type}") 26 | img(src='#{subject.images.medium}') 27 | 28 | mixin overview(people) 29 | - s = people.book_stats 30 | - coll = s.all 31 | p 从 #{people.created.getFullYear()} 年至今,#{people == req.user ? '你' : people.name}一共收藏了 32 | strong.num #{s.total} 33 | | 本图书,其中读过 34 | strong.num #{s.n_done} 35 | | 本,占全部收藏的 36 | em.percent #{s.ratio_done}% 37 | | 。 38 | if s.n_done < 10 39 | 是不是有点太少了呢? 40 | if s.n_wish > 20 && s.ratio_wish > 60 41 | 想读的书有 #{s.n_wish} 本,占全部收藏的 #{s.ratio_wish}% 。 42 | a(href="#{people.db_url('book')}wish", target="db-interests") 想读的 43 | | 似乎有点多了,不要光想不做哦。 44 | if s.n_ing 45 | | 在读的书一共有 #{s.n_ing} 本 46 | if s.n_ing > 30 47 | | ,怎么可能 48 | a(href="#{people.db_url('book')}do", target="db-interests") 同时读 49 | | 这么呢?一本本读完先吧 50 | | 。 51 | -------------------------------------------------------------------------------- /templates/people/stats/book/overview.jade: -------------------------------------------------------------------------------- 1 | include ./mixins 2 | 3 | #overview.row.mod 4 | .col.col-md-4 5 | h3 总览 6 | a.anchor(href="#overview") # 7 | mixin overview(people) 8 | .col.col-md-5 9 | .chart.chart-pie#d-summary(data-pie=people.book_csv('r_status')) 10 | .caption 想读/读过/在读图书比例 11 | .col.col-md-3 12 | h4 最新收藏 13 | if locals.latest_interests 14 | ul.text-muted 15 | each item in latest_interests 16 | li 17 | mixin interest_item(item, 8) 18 | else if people.last_synced_status === 'ing' 19 | p.text-muted 正在重新同步... 20 | else 21 | p 暂时没有 22 | -------------------------------------------------------------------------------- /templates/people/stats/book/quote.jade: -------------------------------------------------------------------------------- 1 | include ./mixins.jade 2 | 3 | #most-commented.row.mod 4 | .col.col-lg-12 5 | h4 体悟 6 | small.text-muted (不包括长篇评论) 7 | a.anchor(href="#most-commented") # 8 | if n_comments > most_commented.length 9 | a.pull-right.btn.btn-default.btn-xs(href="#{people.url()}quote") 全部 》 10 | if most_commented 11 | ul.comments-list 12 | each i in most_commented 13 | if i.comment 14 | li(id="comment-#{i._id}") 15 | mixin comment_item(i) 16 | else 17 | .vmiddle 18 | p.text-muted 收藏时写的短评会出现在这里 19 | -------------------------------------------------------------------------------- /templates/people/stats/book/tags.jade: -------------------------------------------------------------------------------- 1 | #tags.row.mod 2 | .col.col-lg-12 3 | h3 偏好 4 | a.anchor(href="#tags") # 5 | .chart 6 | - tree = JSON.stringify(people.book_tags(istatus.status)) 7 | #d-tags.chart-treemap(data-ns="book", data-tree=tree) 8 | -------------------------------------------------------------------------------- /templates/people/stats/book/tops.jade: -------------------------------------------------------------------------------- 1 | #tops 2 | .row 3 | .col.col-md-12 4 | h3 阅读之最 5 | a.anchor(href="#tops") # 6 | .row.mod 7 | .col.col-md-3 8 | h4 最厚的书 9 | mixin small_list(coll.most_pages.slice(0,5), 'pages', '页') 10 | .col.col-md-3 11 | h4 最薄的书 12 | mixin small_list(coll.least_pages.slice(0,5), 'pages', '页') 13 | .col.col-md-3 14 | h4 最贵的书 15 | mixin small_list(coll.most_price.slice(0,5), 'ori_price') 16 | .col.col-md-3 17 | h4 最便宜的书 18 | mixin small_list(coll.least_price.slice(0,5), 'ori_price') 19 | .row.mod 20 | .col.col-md-3 21 | h4 豆瓣评分最高 22 | mixin small_list(coll.most_rated.slice(0,10), 'rated') 23 | .col.col-md-3 24 | h4 豆瓣评分最低 25 | mixin small_list(coll.least_rated.slice(0,10), 'rated') 26 | .col.col-md-3 27 | h4 最多人看过 28 | mixin small_list(coll.most_raters.slice(0,10), 'raters') 29 | .col.col-md-3 30 | h4 最少人看过 31 | mixin small_list(coll.least_raters.slice(0,10), 'raters') 32 | 33 | mixin small_list(items, prop, suffix) 34 | - suffix = suffix || '' 35 | ol.text-muted.list-small 36 | each b in items 37 | li 38 | a(href="http://book.douban.com/subject/#{b._id || b.id}/", target="db-book") #{trunc(b.title, 9)} 39 | span.label-compact #{b[prop]}#{suffix} 40 | -------------------------------------------------------------------------------- /templates/people/stats/book_sub.jade: -------------------------------------------------------------------------------- 1 | include ../mixins/interest 2 | 3 | - s = people[istatus.ns + '_stats'] 4 | - coll = s[istatus.status] 5 | 6 | if !coll 7 | .vmiddle 8 | p.alert 9 | if istatus.status == 'done' 10 | | 居然什么书都没读过?不可能吧?试试用 11 | a(href="http://book.douban.com/") 豆瓣读书 12 | | 来记录你看过的书。 13 | else 14 | include ./book/tags 15 | 16 | - n_comments = most_commented.length 17 | - most_commented = central.utils.shuffle(most_commented).slice(0,3) 18 | include ./book/quote 19 | 20 | hr 21 | include ./book/keywords 22 | 23 | hr 24 | include ./book/history 25 | 26 | hr 27 | include ./book/tops 28 | 29 | hr 30 | .mod 31 | a.btn.btn-small.btn-primary(href="#{people.url()}") < 返回 #{people.name}的读书酱 32 | -------------------------------------------------------------------------------- /templates/people/sub.jade: -------------------------------------------------------------------------------- 1 | extend ./index 2 | 3 | block article 4 | ul.breadcrumb 5 | li 6 | a(href=people.url()) #{name}的豆瓣酱 7 | li.active 8 | | #{istatus.name} 9 | .pull-right 10 | include ./mods/share.html 11 | h1 #{title} 12 | include ./stats/book_sub 13 | include ./mods/stats_js.jade 14 | -------------------------------------------------------------------------------- /templates/tag/index.jade: -------------------------------------------------------------------------------- 1 | extend ../layout/basic 2 | 3 | block title 4 | title 标签: #{tagname} - 豆瓣酱 5 | 6 | block head_more 7 | style. 8 | !{istatic('css/tag.css')} 9 | 10 | block main 11 | .row 12 | .col.col-md-9 13 | h1 豆瓣上「#{tagname}」相关的书和人 14 | a.btn.btn-xs(href="http://book.douban.com/tag/#{tagname}", target="_blank") 去豆瓣 ⇗ 15 | if books 16 | include ./mods/books.jade 17 | hr 18 | if book_done_users 19 | include ./mods/users.jade 20 | .col.col-md-3 21 | .well 22 | h3 请注意: 23 | p 24 | 这个页面只包括同步过数据到豆瓣酱的用户 25 | | 更多相关信息,请 26 | a(href="http://book.douban.com/tag/#{tagname}", target="_blank") 去豆瓣查询 27 | -------------------------------------------------------------------------------- /templates/tag/mods/books.jade: -------------------------------------------------------------------------------- 1 | include ../../people/mixins/interest 2 | 3 | .mod 4 | h2 热门图书 5 | ol.rbox-list.interest-rboxes 6 | each book in books 7 | if !book 8 | - continue 9 | li 10 | a(href="http://book.douban.com/subject/#{book.id}/", target="db-#{book.type}") 11 | if book.rated 12 | p.rating 13 | mixin stars(Math.round(book.rated / 2)) 14 | | #{book.rated} 15 | if book.images 16 | .pic 17 | img(src=book.images.medium) 18 | p.title #{book.title} 19 | if book.author 20 | p.meta.authors !{book.author.join(', ')} 21 | if book.publisher 22 | p.meta.publisher #{book.publisher} 23 | .rbox-supple 24 | strong #{book._tag_count} 25 | | 人标记 26 | -------------------------------------------------------------------------------- /templates/tag/mods/users.jade: -------------------------------------------------------------------------------- 1 | .mod 2 | h2 读过相关图书最多的人 3 | mixin people_wall(book_done_users) 4 | p.small.text-muted >  5 | a(href="http://www.douban.com/doumail/write?to=69795126") 我不想出现在这里 6 | 7 | mixin people_wall(people) 8 | ol.rbox-list.people-rboxes 9 | for p in people 10 | li 11 | a(href="#{p.url()}read", title="#{p.name}#{p.signature ? ' (' + p.signature + ')' : ''}") 12 | .pic 13 | img(src="#{p.avatar}", width=48, height=48, alt="#{p.name}") 14 | p.title #{p.name} 15 | .rbox-supple 读过 16 | strong #{p._tag_count} 17 | | 本 18 | -------------------------------------------------------------------------------- /templates/toplist/index.jade: -------------------------------------------------------------------------------- 1 | extend ../layout/basic 2 | 3 | block main 4 | h1 豆瓣酱排行榜 5 | hr 6 | section.chart 7 | .row 8 | h2.col.col-md-12 最勤奋的读书人 9 | .col.col-md-4 10 | h3 最近30天 11 | mixin people_wall(hardest_reader_last_30_days) 12 | .col.col-md-4 13 | h3 过去12个月 14 | mixin people_wall(hardest_reader_last_12_month) 15 | .col.col-md-4 16 | h3 一直以来 17 | mixin people_wall(hardest_reader_all_time) 18 | .row 19 | .col.col-md-12 20 | p.text-muted 21 | | *「最勤奋」是指认真撰写阅读感想且阅读量够大
22 | | * 如果你不希望自己的名字出现在这里,请 23 | a(href="mailto:doubanj@yjc.me") 与我们联系
24 | | * 我们提倡严肃阅读, 25 | a(href="https://github.com/ktmud/doubanj/blob/master/models/toplist/book.js#L18", target="_blank")某些类型 26 | | 的收藏在排名时可能会被过滤 27 | 28 | mixin people_wall(people) 29 | ol.rbox-list.people-rboxes.toplist 30 | //people = people.concat(people, people, people, people, people) 31 | for p, i in people 32 | li 33 | - top_color = i < 5 ? 'background: #93d6ef;' : '' 34 | .top-n(style=top_color) #{i + 1} 35 | a(href="#{p.url()}quote", title="#{p.name}#{p.signature ? ' (' + p.signature + ')' : ''}") 36 | .pic 37 | img(src="#{p.avatar}", width=48, height=48, alt="#{p.name}") 38 | p.title #{p.name} 39 | p #{p.book_quote_n}条体悟 40 | mixin tags_list(p.book_stats.all.top_tags) 41 | 42 | mixin tags_list(tags) 43 | list = [] 44 | for t in tags.slice(0,20) 45 | if t._id != '中国' 46 | - list.push(t._id) 47 | p.tags(title="#{list.join('、')}") 48 | | !{trunc(list.join('/'), 11.5)} 49 | -------------------------------------------------------------------------------- /templates/widgets/latest_synced.jade: -------------------------------------------------------------------------------- 1 | #latest-synced.ticker.row 2 | .col.col-md-2.col-sm-3 3 | h4(style="margin-top:-2px") 新鲜出炉: 4 | .col.col-md-10.col-sm-9 5 | .ticker-content 6 | p.text-muted ... 7 | script#tmpl-latest-synced(type="text/tmpl") 8 | include ./tmpl_latest_synced.html 9 | -------------------------------------------------------------------------------- /templates/widgets/tmpl_latest_synced.html: -------------------------------------------------------------------------------- 1 |
    2 | <% _.forEach(people, function(item) { %> 3 |
  • 4 | <%= item.name %> 5 |
  • 6 | <% }); %> 7 |
8 | -------------------------------------------------------------------------------- /tools/clean.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * clean all redis stats cache for users 5 | */ 6 | var log = require('debug')('dbj:tool:update'); 7 | 8 | var User = require('../models/user'); 9 | 10 | var oneday = 60 * 60 * 24 * 1000; 11 | var oneweek = oneday * 7; 12 | 13 | function updateAll() { 14 | var now = new Date(); 15 | 16 | User.stream({ 17 | stats_p: 100 18 | }, { limit: null }, function(stream) { 19 | stream.on('data', function(doc) { 20 | var u = new User(doc); 21 | log('Deleting stats cache for [%s]', u.name); 22 | u._del_data('book_stats'); 23 | }); 24 | stream.on('end', function() { 25 | log('=== Stream ended. ==='); 26 | setTimeout(process.exit, 200); 27 | }); 28 | }); 29 | } 30 | 31 | setTimeout(updateAll, 2000); 32 | -------------------------------------------------------------------------------- /tools/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | export NODE_ENV=${2:-'development'} 3 | export DEBUG='dbj:* -*verbose' 4 | 5 | if [[ -z $1 ]]; then 6 | echo 7 | echo 'Must provide a script name to run.' 8 | echo 9 | exit 1 10 | fi 11 | 12 | # make sure we are at app root 13 | cd `dirname $0`/../ 14 | node "tools/${1}.js" 15 | -------------------------------------------------------------------------------- /tools/toplist.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var toplist = require('../tasks/toplist'); 3 | var async = require('async') 4 | 5 | async.series([ 6 | toplist.by_tag.users.bind(this, 'book', 'done'), 7 | toplist.by_tag.subjects.bind(this, 'book'), 8 | toplist.run.bind(toplist, 20000) 9 | ], process.exit) 10 | -------------------------------------------------------------------------------- /tools/update.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Update all users in background. 5 | */ 6 | var log = require('debug')('dbj:tool:update') 7 | 8 | var User = require('../models/user') 9 | var cached = require('../lib/cached') 10 | var tasks = require('../tasks') 11 | 12 | tasks.setKeyPrefix('dbj-cron-update-') 13 | 14 | var oneday = 60 * 60 * 24 * 1000 15 | var oneweek = oneday * 7 16 | 17 | function updateAll(query) { 18 | var now = new Date() 19 | var canExit = true 20 | var blacklist = [] 21 | var counter = 0 22 | 23 | if (tasks.getQueueLength()) { 24 | log('There are unfinished task. Exit.') 25 | return 26 | } 27 | 28 | cached.get('user_blacklist', function(err, res) { 29 | if (res && res.length) { 30 | blacklist = res 31 | } 32 | }) 33 | 34 | User.stream(query, { limit: null }, function(stream) { 35 | function done(e) { 36 | if (e) { 37 | log('Sync and compute error: %s', e) 38 | } 39 | if (canExit) { 40 | log('==== Updated %s users, exit. ======', counter) 41 | process.exit() 42 | } 43 | } 44 | function resume() { 45 | if (tasks.getQueueLength() < 4) { 46 | stream.resume() 47 | } else { 48 | log('[warning] Too many tasks running, wait next..') 49 | } 50 | } 51 | stream.on('data', function(u) { 52 | if (~blacklist.indexOf(u._id)) { 53 | return resume() 54 | } 55 | canExit = false 56 | stream.pause() 57 | counter += 1 58 | u.pull(function() { 59 | resume() 60 | tasks.interest.collect_book({ 61 | user: u, 62 | force: true, 63 | fresh: false 64 | }) 65 | u.once('computed', function(e) { 66 | resume() 67 | done(e) 68 | }) 69 | }) 70 | log('Queue user %s [%s]', u.uid, u.name) 71 | }) 72 | stream.on('end', function() { 73 | log('===== Stream ended. %s users in queue. =====', counter) 74 | canExit = true 75 | if (counter === 0) { 76 | done() 77 | } 78 | }) 79 | }) 80 | } 81 | 82 | setTimeout(updateAll, 1000, { 83 | last_synced_status: { 84 | $ne: 'ing' 85 | }, 86 | book_n: { 87 | $gt: 500 88 | }, 89 | // 一周之内更新过的用户就不再更新 90 | last_synced: { 91 | $lt: new Date(new Date() - oneweek) 92 | }, 93 | }) 94 | --------------------------------------------------------------------------------