├── .dockerignore ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── certificates ├── rootCA.crt └── rootCA.key ├── client ├── app │ ├── actions.js │ ├── components │ │ ├── Paginator.jsx │ │ ├── edit.jsx │ │ ├── loading.jsx │ │ └── searchInput.jsx │ ├── config.js │ ├── containers │ │ ├── doc.jsx │ │ ├── posts.jsx │ │ ├── profiles.jsx │ │ └── search.jsx │ ├── index.html │ ├── index.jsx │ ├── reducers.js │ └── style │ │ └── style.css ├── build │ ├── 448c34a56d699c29117adc64c43affeb.woff2 │ ├── 674f50d287a8c48dc19ba404d20fe713.eot │ ├── 89889688147bd7575d6327160d64e760.svg │ ├── 912ec66d7572ff821749319396470bde.svg │ ├── af7ae505a9eed503f8b8e6982036873e.woff2 │ ├── b06871f281fee6b241d60582ae9369b9.ttf │ ├── bundle.js │ ├── e18bbf611f2a2e43afc071aa2f4e1512.ttf │ ├── f4769f9bdb7466be65088239c12046d1.eot │ ├── fa2772327f55d8198301fdb8bcfc8158.woff │ ├── fee66e712a8a08eef5805a46892932ad.woff │ └── index.html ├── package-lock.json ├── package.json └── webpack.config.js ├── config.js ├── docker-compose.yml ├── imgs ├── posts_screenshot.png └── sponsor-me.jpeg ├── index.js ├── models ├── Comment.js ├── Post.js ├── Profile.js ├── ProfilePubRecord.js ├── index.js └── plugins │ └── paginator.js ├── package-lock.json ├── package.json ├── rule ├── basicAuth.js ├── getNextProfileLink.js ├── handleImg │ ├── index.js │ └── replaceImg.png ├── handlePostPage.js ├── handleProfileHistoryPage.js ├── index.js ├── insertProfileScript.html ├── postLink.js └── savePostsData.js ├── scripts └── checkWechatId.js ├── server ├── api │ ├── conf.js │ └── index.js ├── index.js └── wrap.js ├── test ├── contentHandler.js ├── exportData.js └── models │ ├── Post.test.js │ ├── Profile.test.js │ └── ProfilePubRecord.test.js └── utils ├── contentHandler.js ├── correctWechatId.js ├── exportData.js ├── helper.js ├── index.js ├── logger.js ├── merge.js └── redis.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | client/app 3 | client/node_modules 4 | client/package-lock.json 5 | client/package.json 6 | client/webpack.config.js 7 | .git 8 | .gitignore 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "globals": { 9 | "describe": true, 10 | "it": true 11 | }, 12 | "extends": ["eslint:recommended", "plugin:react/recommended"], 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "experimentalObjectRestSpread": true, 16 | "jsx": true 17 | }, 18 | "sourceType": "module", 19 | "ecmaVersion": 2017 20 | }, 21 | "plugins": [ 22 | "react" 23 | ], 24 | "rules": { 25 | "no-unused-vars": [ 26 | 1 27 | ], 28 | "no-console": [ 29 | 0 30 | ], 31 | "react/prop-types": [ 32 | 0 33 | ], 34 | "react/no-danger": [ 35 | 1 36 | ], 37 | "indent": [ 38 | 1, 39 | 2, 40 | { "SwitchCase": 1 } 41 | ], 42 | "linebreak-style": [ 43 | 2, 44 | "unix" 45 | ], 46 | "quotes": [ 47 | 1, 48 | "single" 49 | ], 50 | "semi": [ 51 | 2, 52 | "always" 53 | ], 54 | "require-yield": [0] 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | private/ 61 | my_config.js 62 | my_config.json 63 | .DS_Store 64 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 2 | WORKDIR /app 3 | COPY package.json package-lock.json /app/ 4 | RUN npm install --only=prod 5 | COPY . /app 6 | # ubuntu 添加根证书相关操作 7 | RUN cd ~ \ 8 | && mkdir .anyproxy \ 9 | && cd .anyproxy \ 10 | && mv /app/certificates ~/.anyproxy/ \ 11 | && cp ~/.anyproxy/certificates/rootCA.crt /usr/local/share/ca-certificates/ \ 12 | && update-ca-certificates 13 | # 修改时区 14 | RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime 15 | EXPOSE 8101 8104 8102 16 | CMD ["node", "index.js"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 liqiang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wechat_spider 微信爬虫 2 | 3 | 基于 Node.js 的微信爬虫,通过中间人代理的原理,批量获取微信文章数据,包括阅读量、点赞量、在看数、评论和文章正文等数据。 4 | 5 | 使用代理模块 AnyProxy。代码已支持 AnyProxy 4 版本。 6 | 7 | 支持 Docker 部署。 8 | 9 | 项目可运行在个人电脑上,也可部署在服务器上。 10 | 11 | ## 开始 12 | 13 | ### 安装前准备 14 | 15 | - 安装 Node,推荐版本 16 16 | - 安装 MongoDB,最近版本即可 17 | - 安装 Redis,最近版本即可 18 | 19 | ### 安装 20 | 21 | ```bash 22 | git clone https://github.com/lqqyt2423/wechat_spider.git 23 | cd wechat_spider 24 | npm install 25 | ``` 26 | 27 | 本项目基于代理模块 AnyProxy,解析微信 HTTPS 请求需在电脑和手机上都安装证书。可参考:[AnyProxy 文档](http://anyproxy.io/cn/#%E8%AF%81%E4%B9%A6%E9%85%8D%E7%BD%AE)。 28 | 29 | ### 通过 Docker 部署 30 | 31 | ```bash 32 | git clone https://github.com/lqqyt2423/wechat_spider.git 33 | cd wechat_spider 34 | # build image 35 | docker-compose build 36 | # 运行实例(mongo数据存储地址需通过环境变量MONGO_PATH传入) 37 | MONGO_PATH=/data/mongo docker-compose up 38 | # 终止运行 39 | docker-compose down 40 | ``` 41 | 42 | - `Dockerfile` 中已经设置了在 `Linux` 环境的 Docker 中添加根证书的操作步骤,所以接下来仅需在手机上安装 https 证书即可。 43 | - 最终手机上设置的代理 ip 还是需要以自己电脑上的 ip 为准,需忽略 Docker 实例中打印的 ip 地址 44 | - 可编辑 `Dockerfile` 和 `docker-compose.yml` 改变部署规则 45 | 46 | ## 使用 47 | 48 | ```bash 49 | cd wechat_spider 50 | npm start 51 | ``` 52 | 53 | 1. 确保电脑和手机连接同一 WIFI,`npm start` 之后,命令行输出`请配置代理: xx.xx.xx.xx:8101` 类似语句,手机设置代理为此 IP 和端口 54 | 2. 手机上测试打开任一公众号历史文章详情页和文章页,观察电脑命令行的输出,查看数据是否保存至 MongoDB 55 | 56 | > - 如需测试自动翻页,可先多次分别打开不同的公众号的历史详情页,等数据库中有了翻页的基础公众号信息之后,再随便进入历史页等待翻页跳转 57 | > - 翻页逻辑仅支持公众号历史页面跳公众号历史页面,微信文章页面跳微信文章页面,两个不同页面不能互相跳转 58 | 59 | ### 针对微信新版需注意 60 | 61 | 1. 历史页面可自行拼接后发送至微信中打开,拼接规则为: 62 | 63 | ```javascript 64 | var biz = 'MzI4NjQyMTM2Mw=='; 65 | var history_page = 'https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=' + biz + '&scene=124#wechat_redirect'; 66 | // https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=MzI4NjQyMTM2Mw==&scene=124#wechat_redirect 67 | ``` 68 | 69 | 2. 进入微信文章页面先刷新一下 70 | 71 | ### 自定义配置 72 | 73 | 可编辑 `config.js` 文件进行自定义配置,文件中每个配置项都有详细的说明。 74 | 75 | 可配置项举例如下: 76 | 77 | - 控制是否开启文章或历史详情页自动跳转 78 | - 控制跳转时间间隔 79 | - 根据文章发布时间控制抓取范围 80 | - 是否保存文章正文内容 81 | - 是否保存文章评论 82 | 83 | 需注意,本项目修改了 AnyProxy 的默认端口。连接代理的端口改为 8101,AnyProxy 管理界面的端口改为 8102,且仅在 `NODE_ENV=development` 时才会开启 AnyProxy 的管理界面功能。如需修改,可编辑 `config.js`。 84 | 85 | ### 可视化界面 86 | 87 | 前端页面已打包好,启动项目后,如无修改默认 `server port` 配置,浏览器直接访问 `http://localhost:8104` 即可。检测数据有无抓取保存直接刷新此页面即可。 88 | 89 | ![可视化界面](./imgs/posts_screenshot.png) 90 | 91 | 前端页面由 `React` 编写,如需修改,可编辑 `client` 文件中的代码。 92 | 93 | ### MongoDB 数据信息 94 | 95 | 数据库 database: wechat_spider 96 | 97 | 数据表 collections: 98 | 99 | - posts - 文章数据 100 | - profiles - 公众号数据 101 | - comments - 评论数据 102 | 103 | ### 从 MongoDB 导出数据 104 | 105 | #### 命令行直接导出数据 106 | 107 | ```bash 108 | mongoexport --db wechat_spider --collection posts --type=csv --fields title,link,publishAt,readNum,likeNum,likeNum2,msgBiz,msgMid,msgIdx,sourceUrl,cover,digest,isFail --out ~/Desktop/posts.csv 109 | ``` 110 | 111 | #### 脚本导出 112 | 113 | 可参考文件 `/test/exportData.js` 。 114 | 115 | ## 感谢 116 | 117 | 感谢此文章提供思路:[微信公众号文章批量采集系统的构建](https://zhuanlan.zhihu.com/p/24302048) 118 | 119 | ### 赞助我 120 | 121 | 如果你觉得这个项目对你有帮助,不妨考虑给我买杯咖啡。 122 | 123 | 赞助时可备注来源 wechat spider,我会将你添加至下面的赞助列表中。 124 | 125 |
126 | sponsorme 127 |
128 | 129 | 感谢以下赞助者: 130 | 131 | 暂无 132 | 133 | ## License 134 | 135 | [MIT](LICENSE) 136 | -------------------------------------------------------------------------------- /certificates/rootCA.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDoTCCAomgAwIBAgIGDpdPtCH5MA0GCSqGSIb3DQEBCwUAMCgxEjAQBgNVBAMM 3 | CW1pdG1wcm94eTESMBAGA1UECgwJbWl0bXByb3h5MB4XDTIwMTAzMTA4MDAwNVoX 4 | DTIzMTEwMjA4MDAwNVowKDESMBAGA1UEAwwJbWl0bXByb3h5MRIwEAYDVQQKDAlt 5 | aXRtcHJveHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5gS2cg6AO 6 | P0Oppqe8LMywWIotjqowiWTQK/2beubn0YOfejIlYXEmEf3tCUJ8pCVmKVUw4+kw 7 | t46QEfmT3OUf/N5Ec3ZW30ovhLpf0QmiBBtkw26kUQ1wOYyCC4bBmpPn/qXelMeK 8 | Apw9onmOjrRSf66/sRN5rbYGf7n1gYxdz78l/szfC4ywbcssBV1zq40r+g+7Ui2z 9 | h0YrWrdEUC0k6h3diQrBRWbH5SH+QbTSYUaSgW7SCBvB/JiAavU3Um/2XgJY4R6I 10 | d3Ynp9w7UGjpxQFTlt1TL4UzzSVm5RrHynHzBL/MEh1K2YqUlx+hcZu7sm5cWC0a 11 | 3UZ9G2sQmoIfAgMBAAGjgdAwgc0wDwYDVR0TAQH/BAUwAwEB/zARBglghkgBhvhC 12 | AQEEBAMCAgQweAYDVR0lBHEwbwYIKwYBBQUHAwEGCCsGAQUFBwMCBggrBgEFBQcD 13 | BAYIKwYBBQUHAwgGCisGAQQBgjcCARUGCisGAQQBgjcCARYGCisGAQQBgjcKAwEG 14 | CisGAQQBgjcKAwMGCisGAQQBgjcKAwQGCWCGSAGG+EIEATAOBgNVHQ8BAf8EBAMC 15 | AQYwHQYDVR0OBBYEFO1znFAeAclIZCu9TW8RBrkfzT8JMA0GCSqGSIb3DQEBCwUA 16 | A4IBAQBaG664YyROLPnOFit+t+E/Z2MIHorDAW92aAFdfzBVGzzb6qJfoph9Tinn 17 | qsTf8U3XAE7N6iclWG+U3PIB0vQli30jcPZzmOSEOrcu99pSoOnQSvchY1cjxNsy 18 | 4UyuhnLNTR8fA1WzfyUNn6CQNPVIOBYgyDgS1QAZRX0PKb/rSvnBD+r5TDasacLF 19 | x3KkX3t9oHoQ8jdRfPyh0HMGbNQJCumO7h14K/kPGXnQVJDXiByIc1F3+GbrV2LP 20 | PXGCZSRweiqdjeITj2Ku3JapIa8pI4vProNNwoJyZEFfpTOOU4bhiQjwszA9sCVS 21 | 9Hwu3do76p4dcVMC1et/cKJ1nXm7 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /certificates/rootCA.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC5gS2cg6AOP0Op 3 | pqe8LMywWIotjqowiWTQK/2beubn0YOfejIlYXEmEf3tCUJ8pCVmKVUw4+kwt46Q 4 | EfmT3OUf/N5Ec3ZW30ovhLpf0QmiBBtkw26kUQ1wOYyCC4bBmpPn/qXelMeKApw9 5 | onmOjrRSf66/sRN5rbYGf7n1gYxdz78l/szfC4ywbcssBV1zq40r+g+7Ui2zh0Yr 6 | WrdEUC0k6h3diQrBRWbH5SH+QbTSYUaSgW7SCBvB/JiAavU3Um/2XgJY4R6Id3Yn 7 | p9w7UGjpxQFTlt1TL4UzzSVm5RrHynHzBL/MEh1K2YqUlx+hcZu7sm5cWC0a3UZ9 8 | G2sQmoIfAgMBAAECggEBAJt8n1KVMU8/z+MfgXDEzDzzub492oEcaJfkh4oPFgQx 9 | JPZDYkzaxBB4/DH2lPgMThy3gGSeZBMliCVSK7O4b4TEWzlc3lAqkPALfHxbpota 10 | jeuDs/WeynjKg+9s4eLdQiQu2bEbW7VeQr+Ws/S9wH917m9WaVCQPgZsgN47XAA0 11 | eCU/QS4cQ0otq4M5gDW4/wLES2uBvm5r0CWRe+j42l2kF1GnTVoeXVRZttNxfKHP 12 | 85/H2gektq49thuSlkhkkP8FscUvNhB/WEooK9Z6xsFob5NMq+RZ3t9IIh2AYGAR 13 | VuXoxFNdWa+j86+TFRLCVHUAx8xHA+2DzUtSrQW98MECgYEA4aWP7PBSmUC4Rmp2 14 | Wie69NRAogLLOm8E0eKMflXhVZr4gTvKjUeiZczurwUkK3NEZc/ugZGE3AxXmD4h 15 | QyH04BJVcPsIADtAWFM4JDo6tIdJ2uo23w9ZgyAxc/F80hZKDPLduVZ+nILnJByp 16 | kxNF4SR/Bpc2IyKFoCgjupb/VUcCgYEA0nVFEd2yQI/4+SqY5Tabz7VOeUK6UQN6 17 | gEhs1W6DaAPwbtdAOBX7ZzI4QaliqG5bzdcoAsj0JeCvcD/cfJZ/poEl2XnhRKEc 18 | 5z3GnWjUZg2qz6NPjjBsF57YGlhfEQKLrWNTSakJbC2IsGIWDaywdURaeUAm50H1 19 | NgEB8L9/OGkCgYBHDroaJTv9otHk6tXGYkiPnN+VpUeWaSudZLhVeHnzWU/0cn3A 20 | q9RKNpTbbMUNIcliPm6fQtIR8ZkMClSzLVhNz7g0UfkdCYujxOEjF2sxOoFZfPQ2 21 | nkDT+Clal6t1BSvglAKawNAyPU7IonYMKL+SvMl7q9aSjeaCnuFReweBNQKBgBk9 22 | n08JO7uqiL25ciTra7x5jjPU4Ouecy63gPYIDxKGhmuEvVr8p+40g7K8UezJb5E8 23 | YLwUxdNVIzVfM15t3lll29g9WdsVR5YkPpHaZL7onLfSalQvUodysZBXUO+FUqM0 24 | 6mRHNa4Xt/EPkn2JXJBz5jXsj73klzgm3si07tkhAoGABfCiGiycRRDkfZL6jBuC 25 | AlU0Eu5yZyV9u9q1GQoiLtCfWfZh65elTOUmFOnHfPV4f8XkRb6XlWYOaDr6LPEY 26 | PMFERy7scF2n3Ksmn0Egj7SxewfBf4h5+EHTdyELa8gWBbOa9I7eXCHJl0LP+z7X 27 | 6EDF89YqQbSB1lW7WcvszSE= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /client/app/actions.js: -------------------------------------------------------------------------------- 1 | import config from './config'; 2 | 3 | export function assembleUrl(path, params, method) { 4 | path = path || ''; 5 | params = params || {}; 6 | method = method ? method.toLowerCase() : 'get'; 7 | Object.keys(params).forEach(function (key) { 8 | let _path = path.replace(`:${key}`, params[key]); 9 | if (_path === path) { 10 | if (method === 'get') { 11 | if (_path.indexOf('?') === -1) { 12 | _path = `${_path}?${key}=${params[key]}`; 13 | } else { 14 | _path = `${_path}&${key}=${params[key]}`; 15 | } 16 | delete params[key]; 17 | } 18 | } else { 19 | delete params[key]; 20 | } 21 | path = _path; 22 | }); 23 | return path; 24 | } 25 | 26 | export const REQUEST_POSTS = 'REQUEST_POSTS'; 27 | 28 | export function requestPosts() { 29 | return { 30 | type: REQUEST_POSTS 31 | }; 32 | } 33 | 34 | export const RECEIVE_POSTS = 'RECEIVE_POSTS'; 35 | 36 | export function receivePosts(posts) { 37 | return { 38 | type: RECEIVE_POSTS, 39 | posts 40 | }; 41 | } 42 | 43 | export function fetchPosts(query) { 44 | let path = assembleUrl(config.posts, query); 45 | return function (dispatch) { 46 | dispatch(requestPosts()); 47 | return fetch(path).then(res => res.json()).then(posts => { 48 | dispatch(receivePosts(posts)); 49 | }); 50 | }; 51 | } 52 | 53 | export const REQUEST_POST = 'REQUEST_POST'; 54 | 55 | export function requestPost(id) { 56 | return { 57 | type: REQUEST_POST, 58 | id 59 | }; 60 | } 61 | 62 | export const RECEIVE_POST = 'RECEIVE_POST'; 63 | 64 | export function receivePost(post) { 65 | return { 66 | type: RECEIVE_POST, 67 | post 68 | }; 69 | } 70 | 71 | export function fetchPost(id) { 72 | return function (dispatch) { 73 | dispatch(requestPost(id)); 74 | return fetch(`${config.post}/${id}`).then(res => res.json()).then(post => { 75 | dispatch(receivePost(post)); 76 | }); 77 | }; 78 | } 79 | 80 | export const REQUEST_PROFILES = 'REQUEST_PROFILES'; 81 | 82 | export function requestProfiles() { 83 | return { 84 | type: REQUEST_PROFILES 85 | }; 86 | } 87 | 88 | export const RECEIVE_PROFILES = 'RECEIVE_PROFILES'; 89 | 90 | export function receiveProfiles(profiles) { 91 | return { 92 | type: RECEIVE_PROFILES, 93 | profiles 94 | }; 95 | } 96 | 97 | export function fetchProfiles(query) { 98 | let path = assembleUrl(config.profiles, query); 99 | return function (dispatch) { 100 | dispatch(requestProfiles()); 101 | return fetch(path).then(res => res.json()).then(profiles => { 102 | dispatch(receiveProfiles(profiles)); 103 | }); 104 | }; 105 | } 106 | 107 | export const REQUEST_PROFILE = 'REQUEST_PROFILE'; 108 | 109 | export function requestProfile(id) { 110 | return { 111 | type: REQUEST_PROFILE, 112 | id 113 | }; 114 | } 115 | 116 | export const RECEIVE_PROFILE = 'RECEIVE_PROFILE'; 117 | 118 | export function receiveProfile(profile) { 119 | return { 120 | type: RECEIVE_PROFILE, 121 | profile 122 | }; 123 | } 124 | 125 | export function fetchProfile(id) { 126 | return function (dispatch) { 127 | dispatch(requestProfile(id)); 128 | return fetch(`${config.profile}/${id}`).then(res => res.json()).then(profile => { 129 | dispatch(receiveProfile(profile)); 130 | }); 131 | }; 132 | } 133 | 134 | // update post 135 | // TODO: 提取公共 http 请求逻辑 136 | export async function updatePost(id, doc) { 137 | let res = await fetch(`${config.post}/${id}`, { 138 | method: 'PUT', 139 | headers: { 'Content-Type': 'application/json' }, 140 | body: JSON.stringify(doc), 141 | }); 142 | res = res.json(); 143 | return res; 144 | } 145 | 146 | // update profile 147 | export async function updateProfile(id, doc) { 148 | let res = await fetch(`${config.profile}/${id}`, { 149 | method: 'PUT', 150 | headers: { 'Content-Type': 'application/json' }, 151 | body: JSON.stringify(doc), 152 | }); 153 | res = res.json(); 154 | return res; 155 | } 156 | 157 | // message 158 | export const SHOW_MESSAGE = 'SHOW_MESSAGE'; 159 | export const CLOSE_MESSAGE = 'CLOSE_MESSAGE'; 160 | let msgTimeout = null; 161 | export function closeMessage() { 162 | return function (dispatch) { 163 | dispatch({ type: CLOSE_MESSAGE }); 164 | }; 165 | } 166 | export function showMessage(content) { 167 | return function (dispatch) { 168 | if (msgTimeout) { 169 | msgTimeout = null; 170 | clearTimeout(msgTimeout); 171 | } 172 | dispatch({ type: SHOW_MESSAGE, content }); 173 | msgTimeout = setTimeout(() => { 174 | dispatch({ type: CLOSE_MESSAGE }); 175 | }, 1000); 176 | }; 177 | } 178 | 179 | // server side config 180 | export const REQUEST_CONF = 'REQUEST_CONF'; 181 | export const RECEIVE_CONF = 'RECEIVE_CONF'; 182 | export function fetchConf() { 183 | return function (dispatch) { 184 | dispatch({ type: REQUEST_CONF }); 185 | return fetch(config.conf).then(res => res.json()).then(conf => { 186 | dispatch({ type: RECEIVE_CONF, conf }); 187 | }); 188 | }; 189 | } 190 | export async function updateConf(doc) { 191 | let res = await fetch(config.conf, { 192 | method: 'PUT', 193 | headers: { 'Content-Type': 'application/json' }, 194 | body: JSON.stringify(doc), 195 | }); 196 | res = res.json(); 197 | return res; 198 | } 199 | -------------------------------------------------------------------------------- /client/app/components/Paginator.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classnames from 'classnames'; 4 | import assign from 'lodash.assign'; 5 | import get from 'lodash/get'; 6 | import set from 'lodash/set'; 7 | import { assembleUrl } from '../actions'; 8 | 9 | class Paginator extends React.Component { 10 | static get propTypes() { 11 | return { 12 | // action: PropTypes.func.isRequired, 13 | // dispatch: PropTypes.func.isRequired, 14 | action: PropTypes.func, 15 | dispatch: PropTypes.func, 16 | currentPage: PropTypes.number, 17 | perPage: PropTypes.number, 18 | totalPages: PropTypes.number, 19 | pager: PropTypes.number, 20 | query: PropTypes.object, 21 | onChange: PropTypes.func 22 | }; 23 | } 24 | 25 | constructor(props) { 26 | super(props); 27 | this.state = { 28 | perPage: props.perPage || 10, 29 | Page: 1 30 | }; 31 | } 32 | 33 | getValue(name) { 34 | return get(this.state, name); 35 | } 36 | 37 | handleChange(name, callback) { 38 | return (e) => { 39 | let self = this; 40 | if (e.target.type === 'file') { 41 | if (!e.target.files.length) return; 42 | let file = e.target.files[0]; 43 | this.handleFileUpload(file).then(function(data) { 44 | let newState = assign({}, self.state); 45 | set(newState, name, data.data.id); 46 | self.setState(newState, callback); 47 | }); 48 | } else { 49 | let newState = assign({}, self.state); 50 | set(newState, name, e.target.value); 51 | self.setState(newState, callback); 52 | } 53 | }; 54 | } 55 | 56 | loadPage(page, overwrite) { 57 | return () => { 58 | let { query, onChange, pathname, search, history } = this.props; 59 | let { perPage } = this.state; 60 | if (perPage > 50) perPage = 50; 61 | if (search && search.indexOf('?') === 0) { 62 | let searchObj = {}; 63 | search.replace('?', '').split('&').forEach(item => { 64 | let key = item.split('=')[0]; 65 | let value = item.replace(`${key}=`, ''); 66 | searchObj[key] = value; 67 | }); 68 | query = assign({}, query, searchObj); 69 | } 70 | query = assign({}, query, { page, perPage }); 71 | if (overwrite) query._overwrite = true; 72 | 73 | if (typeof onChange === 'function') onChange(page, perPage); 74 | 75 | // dispatch(action(query)); 76 | let path = assembleUrl(pathname, query); 77 | history.push(path); 78 | }; 79 | } 80 | 81 | renderPage(obj) { 82 | obj = obj || {}; 83 | return ( 84 |
  • 85 | 86 | 87 | 88 |
  • 89 | ); 90 | } 91 | 92 | handlePerPageChange() { 93 | const { currentPage } = this.props; 94 | if (this._isRefreshingOnPerPageChange) clearTimeout(this._isRefreshingOnPerPageChange); 95 | 96 | this._isRefreshingOnPerPageChange = setTimeout(() => { 97 | this.loadPage(currentPage, true)(); 98 | }, 300); 99 | } 100 | 101 | changePage(e) { 102 | const { totalPages } = this.props; 103 | let Page = e.target.previousSibling.value||1; 104 | if(Page>totalPages) Page = totalPages; 105 | if(Page<1) Page = 1; 106 | 107 | this.loadPage(Page)(); 108 | } 109 | 110 | handleChangePage(e) { 111 | const { totalPages } = this.props; 112 | let value = e.target.value; 113 | if(value > totalPages) value = totalPages; 114 | if(value < 1) value = ''; 115 | this.setState({Page: value}); 116 | } 117 | 118 | render() { 119 | let self = this; 120 | let { currentPage, totalPages, pager, count } = this.props; 121 | currentPage = currentPage || 1; 122 | totalPages = totalPages || 1; 123 | pager = pager || 5; 124 | let minPage = currentPage - pager; 125 | let maxPage = currentPage + pager; 126 | 127 | function renderMiddlePages() { 128 | let pages = []; 129 | for (let i = 1; i <= totalPages; i++) { 130 | if (i > minPage && i < maxPage) { 131 | pages.push(self.renderPage({ active: currentPage == i, page: i, name: i })); 132 | } 133 | } 134 | 135 | return pages; 136 | } 137 | 138 | if (totalPages == 1) return null; 139 | 140 | return ( 141 | 172 | ); 173 | } 174 | } 175 | 176 | export default Paginator; 177 | -------------------------------------------------------------------------------- /client/app/components/edit.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Paper from 'material-ui/Paper'; 3 | import RaisedButton from 'material-ui/RaisedButton'; 4 | import { showMessage } from '../actions'; 5 | 6 | export default class Edit extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | content: props.content, 11 | }; 12 | this.onClickSave = this.onClickSave.bind(this); 13 | } 14 | 15 | async onClickSave() { 16 | let { content } = this.state; 17 | const { onSave, dispatch } = this.props; 18 | try { 19 | content = JSON.parse(content); 20 | } catch(e) { 21 | dispatch(showMessage('解析文档错误,请检查')); 22 | return; 23 | } 24 | onSave(content); 25 | } 26 | 27 | render() { 28 | const { content } = this.state; 29 | const { isEdit, pathname, history } = this.props; 30 | let showContent; 31 | if (!isEdit) { 32 | showContent = ( 33 |
    34 |
    35 |             {content}
    36 |           
    37 | { 41 | history.replace(`/${pathname}/edit`); 42 | }} 43 | /> 44 |
    45 | ); 46 | } else { 47 | showContent = ( 48 |
    49 |