├── codecov.yml
├── web
├── static
│ ├── .eslintignore
│ ├── favicon.ico
│ ├── build
│ │ ├── logo.png
│ │ ├── vue-loader.conf.js
│ │ ├── check-versions.js
│ │ ├── build.js
│ │ ├── utils.js
│ │ ├── webpack.dev.conf.js
│ │ └── webpack.base.conf.js
│ ├── src
│ │ ├── assets
│ │ │ ├── 401_images
│ │ │ │ └── 401.gif
│ │ │ ├── 404_images
│ │ │ │ ├── 404.png
│ │ │ │ └── 404_cloud.png
│ │ │ ├── default_avatar
│ │ │ │ └── gopher.png
│ │ │ └── custom-theme
│ │ │ │ └── fonts
│ │ │ │ ├── element-icons.ttf
│ │ │ │ └── element-icons.woff
│ │ ├── App.vue
│ │ ├── views
│ │ │ ├── svg-icons
│ │ │ │ ├── generateIconsView.js
│ │ │ │ └── index.vue
│ │ │ ├── layout
│ │ │ │ ├── components
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── AppMain.vue
│ │ │ │ │ └── Sidebar
│ │ │ │ │ │ ├── index.vue
│ │ │ │ │ │ └── SidebarItem.vue
│ │ │ │ ├── mixin
│ │ │ │ │ └── ResizeHandler.js
│ │ │ │ └── Layout.vue
│ │ │ ├── login
│ │ │ │ └── authredirect.vue
│ │ │ ├── dashboard
│ │ │ │ └── index.vue
│ │ │ ├── errorPage
│ │ │ │ └── 401.vue
│ │ │ └── exportdb
│ │ │ │ └── add.vue
│ │ ├── utils
│ │ │ ├── createUniqueString.js
│ │ │ ├── auth.js
│ │ │ ├── i18n.js
│ │ │ ├── permission.js
│ │ │ ├── clipboard.js
│ │ │ ├── openWindow.js
│ │ │ ├── validate.js
│ │ │ └── request.js
│ │ ├── styles
│ │ │ ├── variables.scss
│ │ │ ├── transition.scss
│ │ │ ├── element-ui.scss
│ │ │ ├── mixin.scss
│ │ │ ├── btn.scss
│ │ │ ├── sidebar.scss
│ │ │ └── index.scss
│ │ ├── directive
│ │ │ ├── waves
│ │ │ │ ├── index.js
│ │ │ │ ├── waves.css
│ │ │ │ └── waves.js
│ │ │ ├── permission
│ │ │ │ ├── index.js
│ │ │ │ └── permission.js
│ │ │ └── sticky.js
│ │ ├── store
│ │ │ ├── modules
│ │ │ │ ├── errorLog.js
│ │ │ │ ├── app.js
│ │ │ │ ├── permission.js
│ │ │ │ ├── tagsView.js
│ │ │ │ └── user.js
│ │ │ ├── index.js
│ │ │ └── getters.js
│ │ ├── api
│ │ │ ├── login.js
│ │ │ ├── exportdb.js
│ │ │ └── task.js
│ │ ├── icons
│ │ │ ├── index.js
│ │ │ └── svg
│ │ │ │ ├── chart.svg
│ │ │ │ ├── money.svg
│ │ │ │ ├── guide.svg
│ │ │ │ ├── email.svg
│ │ │ │ ├── component.svg
│ │ │ │ ├── documentation.svg
│ │ │ │ ├── user.svg
│ │ │ │ ├── excel.svg
│ │ │ │ ├── message.svg
│ │ │ │ ├── drag.svg
│ │ │ │ ├── edit.svg
│ │ │ │ ├── icon.svg
│ │ │ │ ├── example.svg
│ │ │ │ ├── lock.svg
│ │ │ │ ├── theme.svg
│ │ │ │ ├── list.svg
│ │ │ │ ├── tab.svg
│ │ │ │ ├── nested.svg
│ │ │ │ ├── star.svg
│ │ │ │ ├── password.svg
│ │ │ │ ├── clipboard.svg
│ │ │ │ ├── peoples.svg
│ │ │ │ ├── language.svg
│ │ │ │ ├── 404.svg
│ │ │ │ ├── shoppingCard.svg
│ │ │ │ ├── bug.svg
│ │ │ │ ├── people.svg
│ │ │ │ ├── table.svg
│ │ │ │ ├── eye.svg
│ │ │ │ ├── international.svg
│ │ │ │ ├── dashboard.svg
│ │ │ │ ├── wechat.svg
│ │ │ │ ├── zip.svg
│ │ │ │ └── form.svg
│ │ ├── errorLog.js
│ │ ├── components
│ │ │ ├── SvgIcon
│ │ │ │ └── index.vue
│ │ │ ├── LangSelect
│ │ │ │ └── index.vue
│ │ │ ├── Breadcrumb
│ │ │ │ └── index.vue
│ │ │ ├── Hamburger
│ │ │ │ └── index.vue
│ │ │ ├── Screenfull
│ │ │ │ └── index.vue
│ │ │ ├── ScrollPane
│ │ │ │ └── index.vue
│ │ │ └── ErrorLog
│ │ │ │ └── index.vue
│ │ ├── lang
│ │ │ ├── index.js
│ │ │ ├── zh.js
│ │ │ └── en.js
│ │ ├── main.js
│ │ ├── filters
│ │ │ └── index.js
│ │ └── permission.js
│ ├── config
│ │ ├── sit.env.js
│ │ ├── dev.env.js
│ │ ├── prod.env.js
│ │ └── index.js
│ ├── .editorconfig
│ ├── .postcssrc.js
│ ├── .babelrc
│ ├── .gitignore
│ ├── index.html
│ └── LICENSE
├── service
│ ├── cron_test.go
│ ├── taskstatus.go
│ ├── checktask.go
│ └── cron.go
├── router
│ ├── task
│ │ ├── lock_test.go
│ │ ├── task_lock.go
│ │ ├── get_task_list.go
│ │ ├── get_task_by_id.go
│ │ ├── stop_task.go
│ │ ├── create_task.go
│ │ ├── restart_task.go
│ │ └── start_task.go
│ ├── user
│ │ └── get_user_info.go
│ ├── exportdb
│ │ ├── delete.go
│ │ ├── get_list.go
│ │ └── create.go
│ ├── rule
│ │ └── get_rules.go
│ └── route.go
├── model
│ ├── model_test.go
│ ├── task_test.go
│ ├── exportdb_test.go
│ ├── exportdb.go
│ ├── user_test.go
│ └── user.go
├── web.go
└── core
│ ├── db.go
│ └── db_test.go
├── gospider-task-create.png
├── gospider-task-list.png
├── gospider-exportdb-list.png
├── common
├── types.go
├── enu.go
└── db.go
├── .travis.yml
├── .gitignore
├── spider
├── task_test.go
├── task.go
├── task_rule_test.go
├── errors.go
└── task_rule.go
├── example_gospider_test.go
├── _example
├── main.go
└── rule
│ └── baidunews
│ └── baidu_news.go
├── LICENSE
└── go.mod
/codecov.yml:
--------------------------------------------------------------------------------
1 | ignore:
2 | - "web/model/autogenerated_*.go"
--------------------------------------------------------------------------------
/web/static/.eslintignore:
--------------------------------------------------------------------------------
1 | build/*.js
2 | config/*.js
3 | src/assets
4 |
--------------------------------------------------------------------------------
/gospider-task-create.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nange/gospider/HEAD/gospider-task-create.png
--------------------------------------------------------------------------------
/gospider-task-list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nange/gospider/HEAD/gospider-task-list.png
--------------------------------------------------------------------------------
/web/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nange/gospider/HEAD/web/static/favicon.ico
--------------------------------------------------------------------------------
/gospider-exportdb-list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nange/gospider/HEAD/gospider-exportdb-list.png
--------------------------------------------------------------------------------
/web/static/build/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nange/gospider/HEAD/web/static/build/logo.png
--------------------------------------------------------------------------------
/web/static/src/assets/401_images/401.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nange/gospider/HEAD/web/static/src/assets/401_images/401.gif
--------------------------------------------------------------------------------
/web/static/src/assets/404_images/404.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nange/gospider/HEAD/web/static/src/assets/404_images/404.png
--------------------------------------------------------------------------------
/web/static/config/sit.env.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | NODE_ENV: '"production"',
3 | ENV_CONFIG: '"sit"',
4 | BASE_API: '"/"'
5 | }
6 |
--------------------------------------------------------------------------------
/web/static/config/dev.env.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | NODE_ENV: '"development"',
3 | ENV_CONFIG: '"dev"',
4 | BASE_API: '"/"'
5 | }
6 |
--------------------------------------------------------------------------------
/web/static/config/prod.env.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | NODE_ENV: '"production"',
3 | ENV_CONFIG: '"prod"',
4 | BASE_API: '"/"'
5 | }
6 |
--------------------------------------------------------------------------------
/web/static/src/assets/404_images/404_cloud.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nange/gospider/HEAD/web/static/src/assets/404_images/404_cloud.png
--------------------------------------------------------------------------------
/web/static/build/vue-loader.conf.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | module.exports = {
4 | //You can set the vue-loader configuration by yourself.
5 | }
6 |
--------------------------------------------------------------------------------
/web/static/src/assets/default_avatar/gopher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nange/gospider/HEAD/web/static/src/assets/default_avatar/gopher.png
--------------------------------------------------------------------------------
/web/static/src/assets/custom-theme/fonts/element-icons.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nange/gospider/HEAD/web/static/src/assets/custom-theme/fonts/element-icons.ttf
--------------------------------------------------------------------------------
/web/static/src/assets/custom-theme/fonts/element-icons.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nange/gospider/HEAD/web/static/src/assets/custom-theme/fonts/element-icons.woff
--------------------------------------------------------------------------------
/web/static/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/web/static/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
--------------------------------------------------------------------------------
/web/static/src/views/svg-icons/generateIconsView.js:
--------------------------------------------------------------------------------
1 | const data = {
2 | state: {
3 | iconsMap: []
4 | },
5 | generate(iconsMap) {
6 | this.state.iconsMap = iconsMap
7 | }
8 | }
9 |
10 | export default data
11 |
--------------------------------------------------------------------------------
/common/types.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | const (
4 | OutputTypeMySQL = "mysql"
5 | OutputTypeCSV = "csv"
6 | OutputTypeStdout = "stdout"
7 | )
8 |
9 | type MTS struct {
10 | ID uint64
11 | Status TaskStatus
12 | }
13 |
--------------------------------------------------------------------------------
/web/static/.postcssrc.js:
--------------------------------------------------------------------------------
1 | // https://github.com/michael-ciniawsky/postcss-load-config
2 |
3 | module.exports = {
4 | "plugins": {
5 | // to edit target browsers: use "browserlist" field in package.json
6 | "autoprefixer": {}
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/web/static/src/utils/createUniqueString.js:
--------------------------------------------------------------------------------
1 | export default function createUniqueString() {
2 | const timestamp = +new Date() + ''
3 | const randomNum = parseInt((1 + Math.random()) * 65536) + ''
4 | return (+(randomNum + timestamp)).toString(32)
5 | }
6 |
--------------------------------------------------------------------------------
/web/static/src/views/layout/components/index.js:
--------------------------------------------------------------------------------
1 | export { default as Navbar } from './Navbar'
2 | export { default as Sidebar } from './Sidebar/index.vue'
3 | export { default as TagsView } from './TagsView'
4 | export { default as AppMain } from './AppMain'
5 |
--------------------------------------------------------------------------------
/web/static/src/styles/variables.scss:
--------------------------------------------------------------------------------
1 | $blue:#324157;
2 | $light-blue:#3A71A8;
3 | $red:#C03639;
4 | $pink: #E65D6E;
5 | $green: #30B08F;
6 | $tiffany: #4AB7BD;
7 | $yellow:#FEC171;
8 | $panGreen: #30B08F;
9 |
10 | //sidebar
11 | $menuBg:#304156;
12 | $subMenuBg:#1f2d3d;
13 | $menuHover:#001528;
14 |
--------------------------------------------------------------------------------
/web/static/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | ["env", { "modules": false }],
4 | "stage-2"
5 | ],
6 | "plugins": ["transform-runtime"],
7 | "comments": false,
8 | "env": {
9 | "test": {
10 | "presets": ["env", "stage-2"],
11 | "plugins": [ "istanbul" ]
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/web/static/src/directive/waves/index.js:
--------------------------------------------------------------------------------
1 | import waves from './waves'
2 |
3 | const install = function(Vue) {
4 | Vue.directive('waves', waves)
5 | }
6 |
7 | if (window.Vue) {
8 | window.waves = waves
9 | Vue.use(install); // eslint-disable-line
10 | }
11 |
12 | waves.install = install
13 | export default waves
14 |
--------------------------------------------------------------------------------
/web/static/src/views/login/authredirect.vue:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/web/static/src/utils/auth.js:
--------------------------------------------------------------------------------
1 | import Cookies from 'js-cookie'
2 |
3 | const TokenKey = 'jwt'
4 |
5 | export function getToken() {
6 | return Cookies.get(TokenKey)
7 | }
8 |
9 | export function setToken(token) {
10 | return Cookies.set(TokenKey, token)
11 | }
12 |
13 | export function removeToken() {
14 | return Cookies.remove(TokenKey)
15 | }
16 |
--------------------------------------------------------------------------------
/web/static/src/directive/permission/index.js:
--------------------------------------------------------------------------------
1 | import permission from './permission'
2 |
3 | const install = function(Vue) {
4 | Vue.directive('permission', permission)
5 | }
6 |
7 | if (window.Vue) {
8 | window['permission'] = permission
9 | Vue.use(install); // eslint-disable-line
10 | }
11 |
12 | permission.install = install
13 | export default permission
14 |
--------------------------------------------------------------------------------
/web/static/src/store/modules/errorLog.js:
--------------------------------------------------------------------------------
1 | const errorLog = {
2 | state: {
3 | logs: []
4 | },
5 | mutations: {
6 | ADD_ERROR_LOG: (state, log) => {
7 | state.logs.push(log)
8 | }
9 | },
10 | actions: {
11 | addErrorLog({ commit }, log) {
12 | commit('ADD_ERROR_LOG', log)
13 | }
14 | }
15 | }
16 |
17 | export default errorLog
18 |
--------------------------------------------------------------------------------
/web/service/cron_test.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "log"
5 | "testing"
6 |
7 | "github.com/robfig/cron"
8 | )
9 |
10 | type testJob struct {
11 | c *cron.Cron
12 | }
13 |
14 | func (j testJob) Run() {
15 | log.Println("run once...")
16 | }
17 |
18 | func TestCron(t *testing.T) {
19 | c := cron.New()
20 | c.AddJob("0 */1 * * * *", testJob{c: c})
21 | c.Start()
22 |
23 | }
24 |
--------------------------------------------------------------------------------
/web/static/src/utils/i18n.js:
--------------------------------------------------------------------------------
1 | // translate router.meta.title, be used in breadcrumb sidebar tagsview
2 | export function generateTitle(title) {
3 | const hasKey = this.$te('route.' + title)
4 | const translatedTitle = this.$t('route.' + title) // $t :this method from vue-i18n, inject in @/lang/index.js
5 |
6 | if (hasKey) {
7 | return translatedTitle
8 | }
9 | return title
10 | }
11 |
--------------------------------------------------------------------------------
/web/static/src/views/dashboard/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
18 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: go
2 | sudo: false
3 | go:
4 | - 1.11.x
5 | - 1.12.x
6 |
7 | env:
8 | - GO111MODULE=on
9 |
10 | cache:
11 | directories:
12 | - $HOME/.cache/go-build
13 | - $HOME/gopath/pkg/mod
14 |
15 | script:
16 | - go vet ./...
17 | - go test -race -v -coverprofile=coverage.txt -covermode=atomic ./...
18 | - go build
19 |
20 | after_success:
21 | - bash <(curl -s https://codecov.io/bash)
--------------------------------------------------------------------------------
/web/static/.gitignore:
--------------------------------------------------------------------------------
1 | # maven ignore
2 | target/
3 |
4 | # eclipse ignore
5 | .settings/
6 | .project
7 | .classpath
8 |
9 | # idea ignore
10 | .idea/
11 | *.ipr
12 | *.iml
13 | *.iws
14 |
15 | # temp ignore
16 | *.log
17 | *.cache
18 | *.diff
19 | *.patch
20 | *.tmp
21 |
22 | # system ignore
23 | .DS_Store
24 | Thumbs.db
25 |
26 | # project ignore
27 | **/tmp
28 | pom.xml.versionsBackup
29 |
30 | # node ignore
31 | node_modules/
32 | dist/
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.dll
4 | *.so
5 | *.dylib
6 |
7 | # Test binary, build with `go test -c`
8 | *.test
9 |
10 | # Output of the go coverage tool, specifically when used with LiteIDE
11 | *.out
12 |
13 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
14 | .glide/
15 |
16 | .DS_Store
17 | .idea/
18 | node_modules/
19 | _example/_example
20 | main
21 | csv_output/
22 |
23 | package-lock.json
--------------------------------------------------------------------------------
/spider/task_test.go:
--------------------------------------------------------------------------------
1 | package spider
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestTask(t *testing.T) {
11 | _, cancel := context.WithCancel(context.Background())
12 | err := addTaskCtrl(1, cancel)
13 | assert.Nil(t, err)
14 | err = addTaskCtrl(1, cancel)
15 | assert.NotNil(t, err)
16 |
17 | ok := CancelTask(1)
18 | assert.True(t, ok)
19 | ok = CancelTask(1)
20 | assert.False(t, ok)
21 | }
22 |
--------------------------------------------------------------------------------
/web/static/src/api/login.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | export function loginByUsername(username, password) {
4 | const data = {
5 | username,
6 | password
7 | }
8 | return request({
9 | url: '/login',
10 | method: 'post',
11 | data
12 | })
13 | }
14 |
15 | export function getUserInfo(token) {
16 | return request({
17 | url: '/api/user/info',
18 | method: 'get',
19 | params: { token }
20 | })
21 | }
22 |
23 |
--------------------------------------------------------------------------------
/web/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | gospider
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/web/router/task/lock_test.go:
--------------------------------------------------------------------------------
1 | package task
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 | "time"
7 | )
8 |
9 | func TestLock(t *testing.T) {
10 | for i := 0; i < 3; i++ {
11 | taskID := uint64(1)
12 | go op1(taskID)
13 | }
14 | time.Sleep(5 * time.Second)
15 | }
16 |
17 | func op1(taskID uint64) {
18 | if taskLock.IsRunning(taskID) {
19 | fmt.Println("task is running...")
20 | return
21 | }
22 | defer taskLock.Complete(taskID)
23 | fmt.Println("run task")
24 | time.Sleep(1 * time.Second)
25 | }
26 |
--------------------------------------------------------------------------------
/web/static/src/store/index.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Vuex from 'vuex'
3 | import app from './modules/app'
4 | import errorLog from './modules/errorLog'
5 | import permission from './modules/permission'
6 | import tagsView from './modules/tagsView'
7 | import user from './modules/user'
8 | import getters from './getters'
9 |
10 | Vue.use(Vuex)
11 |
12 | const store = new Vuex.Store({
13 | modules: {
14 | app,
15 | errorLog,
16 | permission,
17 | tagsView,
18 | user
19 | },
20 | getters
21 | })
22 |
23 | export default store
24 |
--------------------------------------------------------------------------------
/web/static/src/icons/index.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import SvgIcon from '@/components/SvgIcon'// svg组件
3 | import generateIconsView from '@/views/svg-icons/generateIconsView.js'// just for @/views/icons , you can delete it
4 |
5 | // register globally
6 | Vue.component('svg-icon', SvgIcon)
7 |
8 | const requireAll = requireContext => requireContext.keys().map(requireContext)
9 | const req = require.context('./svg', false, /\.svg$/)
10 | const iconMap = requireAll(req)
11 |
12 | generateIconsView.generate(iconMap) // just for @/views/icons , you can delete it
13 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/chart.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example_gospider_test.go:
--------------------------------------------------------------------------------
1 | package gospider_test
2 |
3 | import (
4 | "github.com/nange/gospider"
5 | log "github.com/sirupsen/logrus"
6 | )
7 |
8 | //quitstart
9 | func Example() {
10 | // if gospider.New() has no argments, will use env parameters
11 | // gs := gospider.New()
12 |
13 | gs := gospider.New(
14 | gospider.BackendMySQL(),
15 | gospider.MySQLHost("127.0.0.1"),
16 | gospider.MySQLPort(3306),
17 | gospider.MySQLDBName("test"),
18 | gospider.MySQLUser("root"),
19 | gospider.MySQLPassword(""),
20 | gospider.WebPort(8080),
21 | )
22 | log.Fatal(gs.Run())
23 | }
24 |
--------------------------------------------------------------------------------
/_example/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/nange/gospider"
5 | _ "github.com/nange/gospider/_example/rule/baidunews"
6 | _ "github.com/nange/gospider/_example/rule/dianping"
7 | _ "github.com/nange/gospider/_example/rule/mojitianqi"
8 | _ "github.com/nange/gospider/_example/rule/stackoverflow"
9 | log "github.com/sirupsen/logrus"
10 | )
11 |
12 | func init() {
13 | log.SetFormatter(&log.JSONFormatter{TimestampFormat: "2006-01-02 15:04:05.000"})
14 | log.SetLevel(log.DebugLevel)
15 | }
16 |
17 | func main() {
18 | gs := gospider.New()
19 | log.Fatal(gs.Run())
20 | }
21 |
--------------------------------------------------------------------------------
/web/static/src/api/exportdb.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | // 数据列表
4 | export function fetchExportDBList(params) {
5 | return request({
6 | url: '/api/exportdb',
7 | method: 'get',
8 | params
9 | })
10 | }
11 |
12 | // 创建导出数据库记录
13 | export function createExportDB(data) {
14 | return request({
15 | url: '/api/exportdb',
16 | method: 'post',
17 | data
18 | })
19 | }
20 |
21 | // 删除导出数据库记录
22 | export function deleteExportDB(params) {
23 | return request({
24 | url: '/api/exportdb/' + params.id,
25 | method: 'delete'
26 | })
27 | }
28 |
29 |
--------------------------------------------------------------------------------
/web/router/user/get_user_info.go:
--------------------------------------------------------------------------------
1 | package user
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 |
7 | jwt "github.com/appleboy/gin-jwt/v2"
8 | "github.com/gin-gonic/gin"
9 | log "github.com/sirupsen/logrus"
10 | )
11 |
12 | func GetUserInfo(c *gin.Context) {
13 | v, exists := c.Get(jwt.IdentityKey)
14 | if !exists {
15 | log.Warnf("jwt identity key not found")
16 | c.AbortWithStatus(http.StatusBadRequest)
17 | return
18 | }
19 | m := v.(map[string]interface{})
20 | log.Debugf("identity:%+v", m)
21 |
22 | rolesStr := m["roles"].(string)
23 | m["roles"] = strings.Split(rolesStr, ",")
24 |
25 | c.JSON(http.StatusOK, m)
26 | }
27 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/money.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/router/task/task_lock.go:
--------------------------------------------------------------------------------
1 | package task
2 |
3 | import (
4 | "sync"
5 | )
6 |
7 | var taskLock *TaskLock
8 |
9 | type TaskLock struct {
10 | taskLock map[uint64]bool
11 | sync.Mutex
12 | }
13 |
14 | func init() {
15 | taskLock = &TaskLock{
16 | taskLock: make(map[uint64]bool),
17 | }
18 | }
19 |
20 | func (tl *TaskLock) IsRunning(taskid uint64) bool {
21 | tl.Lock()
22 | defer tl.Unlock()
23 | if tl.taskLock[taskid] {
24 | return true
25 | }
26 | tl.taskLock[taskid] = true
27 | return false
28 | }
29 |
30 | func (tl *TaskLock) Complete(taskid uint64) {
31 | tl.Lock()
32 | defer tl.Unlock()
33 | delete(tl.taskLock, taskid)
34 | }
35 |
--------------------------------------------------------------------------------
/web/static/src/errorLog.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import store from './store'
3 |
4 | // you can set only in production env show the error-log
5 | // if (process.env.NODE_ENV === 'production') {
6 |
7 | Vue.config.errorHandler = function(err, vm, info, a) {
8 | // Don't ask me why I use Vue.nextTick, it just a hack.
9 | // detail see https://forum.vuejs.org/t/dispatch-in-vue-config-errorhandler-has-some-problem/23500
10 | Vue.nextTick(() => {
11 | store.dispatch('addErrorLog', {
12 | err,
13 | vm,
14 | info,
15 | url: window.location.href
16 | })
17 | console.error(err, info)
18 | })
19 | }
20 |
21 | // }
22 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/guide.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/email.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/component.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/directive/permission/permission.js:
--------------------------------------------------------------------------------
1 |
2 | import store from '@/store'
3 |
4 | export default{
5 | inserted(el, binding, vnode) {
6 | const { value } = binding
7 | const roles = store.getters && store.getters.roles
8 |
9 | if (value && value instanceof Array && value.length > 0) {
10 | const permissionRoles = value
11 |
12 | const hasPermission = roles.some(role => {
13 | return permissionRoles.includes(role)
14 | })
15 |
16 | if (!hasPermission) {
17 | el.parentNode && el.parentNode.removeChild(el)
18 | }
19 | } else {
20 | throw new Error(`need roles! Like v-permission="['admin','editor']"`)
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/web/static/src/utils/permission.js:
--------------------------------------------------------------------------------
1 | import store from '@/store'
2 |
3 | /**
4 | * @param {Array} value
5 | * @returns {Boolean}
6 | * @example see @/views/permission/directive.vue
7 | */
8 | export default function checkPermission(value) {
9 | if (value && value instanceof Array && value.length > 0) {
10 | const roles = store.getters && store.getters.roles
11 | const permissionRoles = value
12 |
13 | const hasPermission = roles.some(role => {
14 | return permissionRoles.includes(role)
15 | })
16 |
17 | if (!hasPermission) {
18 | return false
19 | }
20 | return true
21 | } else {
22 | console.error(`need roles! Like v-permission="['admin','editor']"`)
23 | return false
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/documentation.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/model/model_test.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "database/sql"
5 | "testing"
6 |
7 | "github.com/DATA-DOG/go-sqlmock"
8 | "github.com/jinzhu/gorm"
9 | "github.com/stretchr/testify/suite"
10 | )
11 |
12 | type testModelSuite struct {
13 | suite.Suite
14 | db *sql.DB
15 | mock sqlmock.Sqlmock
16 | gdb *gorm.DB
17 | }
18 |
19 | func (s *testModelSuite) SetupSuite() {
20 | db, mock, err := sqlmock.New()
21 | s.Require().NoError(err)
22 | s.db = db
23 | s.mock = mock
24 | gdb, err := gorm.Open("mysql", db)
25 | s.Require().NoError(err)
26 | s.gdb = gdb
27 | }
28 |
29 | func (s *testModelSuite) TearDownSuite() {
30 | if s.db != nil {
31 | s.db.Close()
32 | }
33 | }
34 |
35 | func TestModel(t *testing.T) {
36 | suite.Run(t, new(testModelSuite))
37 | }
38 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/user.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/store/getters.js:
--------------------------------------------------------------------------------
1 | const getters = {
2 | sidebar: state => state.app.sidebar,
3 | language: state => state.app.language,
4 | device: state => state.app.device,
5 | visitedViews: state => state.tagsView.visitedViews,
6 | cachedViews: state => state.tagsView.cachedViews,
7 | token: state => state.user.token,
8 | avatar: state => state.user.avatar,
9 | name: state => state.user.name,
10 | introduction: state => state.user.introduction,
11 | status: state => state.user.status,
12 | roles: state => state.user.roles,
13 | setting: state => state.user.setting,
14 | permission_routers: state => state.permission.routers,
15 | addRouters: state => state.permission.addRouters,
16 | errorLogs: state => state.errorLog.logs
17 | }
18 | export default getters
19 |
--------------------------------------------------------------------------------
/web/static/src/views/layout/components/AppMain.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
24 |
25 |
33 |
34 |
--------------------------------------------------------------------------------
/web/router/exportdb/delete.go:
--------------------------------------------------------------------------------
1 | package exportdb
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/nange/gospider/web/core"
9 | "github.com/nange/gospider/web/model"
10 | log "github.com/sirupsen/logrus"
11 | )
12 |
13 | func DeleteExportDB(c *gin.Context) {
14 | idStr := c.Param("id")
15 | log.Infof("delete exportdb id [%v]", idStr)
16 | id, err := strconv.ParseUint(idStr, 10, 64)
17 | if err != nil {
18 | c.AbortWithStatus(http.StatusBadRequest)
19 | return
20 | }
21 | edb := &model.ExportDB{ID: id}
22 | if err := edb.Delete(core.GetGormDB()); err != nil {
23 | log.Errorf("delete export db err [%+v]", err)
24 | c.AbortWithStatus(http.StatusInternalServerError)
25 | return
26 | }
27 |
28 | c.Data(http.StatusNoContent, "", nil)
29 | }
30 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/excel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/components/SvgIcon/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
33 |
34 |
43 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/message.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/utils/clipboard.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Clipboard from 'clipboard'
3 |
4 | function clipboardSuccess() {
5 | Vue.prototype.$message({
6 | message: 'Copy successfully',
7 | type: 'success',
8 | duration: 1500
9 | })
10 | }
11 |
12 | function clipboardError() {
13 | Vue.prototype.$message({
14 | message: 'Copy failed',
15 | type: 'error'
16 | })
17 | }
18 |
19 | export default function handleClipboard(text, event) {
20 | const clipboard = new Clipboard(event.target, {
21 | text: () => text
22 | })
23 | clipboard.on('success', () => {
24 | clipboardSuccess()
25 | clipboard.off('error')
26 | clipboard.off('success')
27 | clipboard.destroy()
28 | })
29 | clipboard.on('error', () => {
30 | clipboardError()
31 | clipboard.off('error')
32 | clipboard.off('success')
33 | clipboard.destroy()
34 | })
35 | clipboard.onClick(event)
36 | }
37 |
--------------------------------------------------------------------------------
/web/static/src/views/layout/components/Sidebar/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
13 |
14 |
15 |
16 |
17 |
34 |
--------------------------------------------------------------------------------
/web/static/src/styles/transition.scss:
--------------------------------------------------------------------------------
1 | //globl transition css
2 |
3 | /*fade*/
4 | .fade-enter-active,
5 | .fade-leave-active {
6 | transition: opacity 0.28s;
7 | }
8 |
9 | .fade-enter,
10 | .fade-leave-active {
11 | opacity: 0;
12 | }
13 |
14 | /*fade-transform*/
15 | .fade-transform-leave-active,
16 | .fade-transform-enter-active {
17 | transition: all .5s;
18 | }
19 | .fade-transform-enter {
20 | opacity: 0;
21 | transform: translateX(-30px);
22 | }
23 | .fade-transform-leave-to {
24 | opacity: 0;
25 | transform: translateX(30px);
26 | }
27 |
28 | /*breadcrumb transition*/
29 | .breadcrumb-enter-active,
30 | .breadcrumb-leave-active {
31 | transition: all .5s;
32 | }
33 |
34 | .breadcrumb-enter,
35 | .breadcrumb-leave-active {
36 | opacity: 0;
37 | transform: translateX(20px);
38 | }
39 |
40 | .breadcrumb-move {
41 | transition: all .5s;
42 | }
43 |
44 | .breadcrumb-leave-active {
45 | position: absolute;
46 | }
47 |
48 |
--------------------------------------------------------------------------------
/web/web.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/nange/gospider/web/core"
8 | "github.com/nange/gospider/web/model"
9 | "github.com/nange/gospider/web/router"
10 | "github.com/nange/gospider/web/service"
11 | "github.com/pkg/errors"
12 | )
13 |
14 | type Server struct {
15 | IP string
16 | Port int
17 | }
18 |
19 | func (s *Server) Run() error {
20 | if err := core.AutoMigrate(); err != nil {
21 | return errors.Wrap(err, "model auto migrate failed")
22 | }
23 | if err := model.InitAdminUserIfNeeded(core.GetGormDB()); err != nil {
24 | return errors.Wrap(err, "init admin user failed")
25 | }
26 |
27 | // 启动服务时,先检查task相关状态
28 | go service.CheckTask()
29 | // 管理task状态(如task运行完成之后需要将状态标为已完成)
30 | go service.ManageTaskStatus()
31 |
32 | engine := gin.Default()
33 | router.Route(engine)
34 |
35 | return errors.WithStack(engine.Run(fmt.Sprintf("%s:%d", s.IP, s.Port)))
36 | }
37 |
--------------------------------------------------------------------------------
/web/static/src/directive/waves/waves.css:
--------------------------------------------------------------------------------
1 | .waves-ripple {
2 | position: absolute;
3 | border-radius: 100%;
4 | background-color: rgba(0, 0, 0, 0.15);
5 | background-clip: padding-box;
6 | pointer-events: none;
7 | -webkit-user-select: none;
8 | -moz-user-select: none;
9 | -ms-user-select: none;
10 | user-select: none;
11 | -webkit-transform: scale(0);
12 | -ms-transform: scale(0);
13 | transform: scale(0);
14 | opacity: 1;
15 | }
16 |
17 | .waves-ripple.z-active {
18 | opacity: 0;
19 | -webkit-transform: scale(2);
20 | -ms-transform: scale(2);
21 | transform: scale(2);
22 | -webkit-transition: opacity 1.2s ease-out, -webkit-transform 0.6s ease-out;
23 | transition: opacity 1.2s ease-out, -webkit-transform 0.6s ease-out;
24 | transition: opacity 1.2s ease-out, transform 0.6s ease-out;
25 | transition: opacity 1.2s ease-out, transform 0.6s ease-out, -webkit-transform 0.6s ease-out;
26 | }
--------------------------------------------------------------------------------
/web/static/src/lang/index.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import VueI18n from 'vue-i18n'
3 | import Cookies from 'js-cookie'
4 | import elementEnLocale from 'element-ui/lib/locale/lang/en' // element-ui lang
5 | import elementZhLocale from 'element-ui/lib/locale/lang/zh-CN'// element-ui lang
6 | import enLocale from './en'
7 | import zhLocale from './zh'
8 |
9 | Vue.use(VueI18n)
10 |
11 | const messages = {
12 | en: {
13 | ...enLocale,
14 | ...elementEnLocale
15 | },
16 | zh: {
17 | ...zhLocale,
18 | ...elementZhLocale
19 | }
20 | }
21 |
22 | const i18n = new VueI18n({
23 | // set locale
24 | // options: en or zh
25 | locale: Cookies.get('language') || getBrowserLanguage(),
26 | // set locale messages
27 | messages
28 | })
29 |
30 | export function getBrowserLanguage() {
31 | const lang = navigator.language || navigator.browserLanguage || navigator.userLanguage
32 | return lang.substr(0, 2)
33 | }
34 |
35 | export default i18n
36 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/drag.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/main.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 |
3 | import 'normalize.css/normalize.css' // A modern alternative to CSS resets
4 |
5 | import Element from 'element-ui'
6 | import 'element-ui/lib/theme-chalk/index.css'
7 |
8 | import '@/styles/index.scss' // global css
9 |
10 | import App from './App'
11 | import router from './router'
12 | import store from './store'
13 |
14 | import i18n from './lang' // Internationalization
15 | import './icons' // icon
16 | import './errorLog' // error log
17 | import './permission' // permission control
18 |
19 | import * as filters from './filters' // global filters
20 |
21 | Vue.use(Element, {
22 | size: 'medium', // set element-ui default size
23 | i18n: (key, value) => i18n.t(key, value)
24 | })
25 |
26 | // register global utility filters.
27 | Object.keys(filters).forEach(key => {
28 | Vue.filter(key, filters[key])
29 | })
30 |
31 | Vue.config.productionTip = false
32 |
33 | new Vue({
34 | el: '#app',
35 | router,
36 | store,
37 | i18n,
38 | render: h => h(App)
39 | })
40 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/edit.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/example.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/service/taskstatus.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "github.com/nange/gospider/common"
5 | "github.com/nange/gospider/web/core"
6 | "github.com/nange/gospider/web/model"
7 | "github.com/pkg/errors"
8 | log "github.com/sirupsen/logrus"
9 | )
10 |
11 | var mtsCh = make(chan common.MTS, 1)
12 |
13 | func GetMTSChan() chan common.MTS {
14 | return mtsCh
15 | }
16 |
17 | func ManageTaskStatus() {
18 | log.Infof("starting manage task status goroutine")
19 | for {
20 | select {
21 | case mts := <-mtsCh:
22 | task := &model.Task{}
23 | err := model.NewTaskQuerySet(core.GetGormDB()).IDEq(mts.ID).One(task)
24 | if err != nil {
25 | log.Errorf("query model task err: %+v", err)
26 | break
27 | }
28 |
29 | task.Status = mts.Status
30 | if mts.Status == common.TaskStatusCompleted {
31 | task.Counts += 1
32 | }
33 |
34 | if err := task.Update(core.GetGormDB(), model.TaskDBSchema.Status, model.TaskDBSchema.Counts); err != nil {
35 | log.Errorf("update task status err:%+v", errors.WithStack(err))
36 | break
37 | }
38 |
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/lock.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/theme.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/web/router/task/get_task_list.go:
--------------------------------------------------------------------------------
1 | package task
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/nange/gospider/web/core"
8 | "github.com/nange/gospider/web/model"
9 | log "github.com/sirupsen/logrus"
10 | )
11 |
12 | type GetTaskListReq struct {
13 | Size int `json:"size" form:"size"`
14 | Offset int `json:"offset" form:"offset"`
15 | }
16 |
17 | type GetTaskListResp struct {
18 | Total int `json:"total"`
19 | Data []model.Task `json:"data"`
20 | }
21 |
22 | func GetTaskList(c *gin.Context) {
23 | var req GetTaskListReq
24 | if err := c.BindQuery(&req); err != nil {
25 | log.Warnf("query param is invalid")
26 | c.String(http.StatusBadRequest, "")
27 | return
28 | }
29 | log.Infof("get task list req:%+v", req)
30 |
31 | tasks, count, err := model.GetTaskList(core.GetGormDB(), req.Size, req.Offset)
32 | if err != nil {
33 | log.Errorf("GetTaskList failed! err:%+v", err)
34 | c.String(http.StatusInternalServerError, "")
35 | return
36 | }
37 |
38 | c.JSON(http.StatusOK, &GetTaskListResp{
39 | Total: count,
40 | Data: tasks,
41 | })
42 | }
43 |
--------------------------------------------------------------------------------
/web/router/task/get_task_by_id.go:
--------------------------------------------------------------------------------
1 | package task
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/nange/gospider/web/core"
9 | "github.com/nange/gospider/web/model"
10 | "github.com/sirupsen/logrus"
11 | )
12 |
13 | func GetTaskByID(c *gin.Context) {
14 | taskIDStr := c.Param("id")
15 | if taskIDStr == "" {
16 | logrus.Warnf("GetTaskByID taskID is empty")
17 | c.AbortWithStatus(http.StatusBadRequest)
18 | return
19 | }
20 | taskID, err := strconv.ParseUint(taskIDStr, 10, 64)
21 | if err != nil {
22 | logrus.Warnf("GetTaskByID taskID format is invalid, taskID:%v", taskIDStr)
23 | c.AbortWithStatus(http.StatusBadRequest)
24 | return
25 | }
26 | logrus.Infof("GetTaskByID:%+v", taskID)
27 |
28 | // query task info from db
29 | task := &model.Task{}
30 | err = model.NewTaskQuerySet(core.GetGormDB()).IDEq(taskID).One(task)
31 | if err != nil {
32 | logrus.Errorf("GetTaskByID query model task fail, taskID: %v , err: %+v", taskIDStr, err)
33 | c.String(http.StatusInternalServerError, "")
34 | return
35 | }
36 |
37 | c.JSON(http.StatusOK, task)
38 | }
39 |
--------------------------------------------------------------------------------
/web/static/src/utils/openWindow.js:
--------------------------------------------------------------------------------
1 | export default function openWindow(url, title, w, h) {
2 | // Fixes dual-screen position Most browsers Firefox
3 | const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : screen.left
4 | const dualScreenTop = window.screenTop !== undefined ? window.screenTop : screen.top
5 |
6 | const width = window.innerWidth ? window.innerWidth : document.documentElement.clientWidth ? document.documentElement.clientWidth : screen.width
7 | const height = window.innerHeight ? window.innerHeight : document.documentElement.clientHeight ? document.documentElement.clientHeight : screen.height
8 |
9 | const left = ((width / 2) - (w / 2)) + dualScreenLeft
10 | const top = ((height / 2) - (h / 2)) + dualScreenTop
11 | const newWindow = window.open(url, title, 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=yes, copyhistory=no, width=' + w + ', height=' + h + ', top=' + top + ', left=' + left)
12 |
13 | // Puts focus on the newWindow
14 | if (window.focus) {
15 | newWindow.focus()
16 | }
17 | }
18 |
19 |
--------------------------------------------------------------------------------
/web/core/db.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "database/sql"
5 | "reflect"
6 |
7 | "github.com/jinzhu/gorm"
8 | "github.com/pkg/errors"
9 | )
10 |
11 | var modelList []Model
12 |
13 | var db *gorm.DB
14 |
15 | func SetGormDB(gdb *gorm.DB) {
16 | db = gdb
17 | }
18 |
19 | func GetGormDB() *gorm.DB {
20 | return db
21 | }
22 |
23 | func GetDB() *sql.DB {
24 | return db.DB()
25 | }
26 |
27 | type Model interface {
28 | TableName() string
29 | }
30 |
31 | func Register(model Model) {
32 | rv := reflect.ValueOf(model)
33 | if rv.IsNil() {
34 | panic("register model failed, model is nil")
35 | }
36 | for _, m := range modelList {
37 | if m.TableName() == model.TableName() {
38 | panic("register model failed, already have the table name:" + model.TableName())
39 | }
40 | }
41 | modelList = append(modelList, model)
42 | }
43 |
44 | func AutoMigrate() error {
45 | for _, model := range modelList {
46 | if err := db.Debug().Set("gorm:table_options", "CHARSET=utf8mb4").AutoMigrate(model).Error; err != nil {
47 | return errors.Wrap(err, "db auto migrate failed")
48 | }
49 | }
50 |
51 | return nil
52 | }
53 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 LanceLi
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 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/list.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/components/LangSelect/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 中文
8 | English
9 |
10 |
11 |
12 |
13 |
32 |
33 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/web/static/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017-present PanJiaChen
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 |
--------------------------------------------------------------------------------
/spider/task.go:
--------------------------------------------------------------------------------
1 | package spider
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "sync"
7 |
8 | "github.com/pkg/errors"
9 | )
10 |
11 | // Task is a task define
12 | type Task struct {
13 | ID uint64
14 | TaskRule
15 | TaskConfig
16 | }
17 |
18 | // NewTask return a new task object
19 | func NewTask(id uint64, rule TaskRule, config TaskConfig) *Task {
20 | return &Task{
21 | ID: id,
22 | TaskRule: rule,
23 | TaskConfig: config,
24 | }
25 | }
26 |
27 | var (
28 | ctlMu = &sync.RWMutex{}
29 | ctlMap = make(map[uint64]context.CancelFunc)
30 | )
31 |
32 | func addTaskCtrl(taskID uint64, cancelFunc context.CancelFunc) error {
33 | ctlMu.Lock()
34 | defer ctlMu.Unlock()
35 |
36 | if _, ok := ctlMap[taskID]; ok {
37 | return errors.WithStack(fmt.Errorf("duplicate taskID:%d", taskID))
38 | }
39 | ctlMap[taskID] = cancelFunc
40 |
41 | return nil
42 | }
43 |
44 | // CancelTask cancel a task by taskID
45 | func CancelTask(taskID uint64) bool {
46 | ctlMu.Lock()
47 | defer ctlMu.Unlock()
48 |
49 | cancel, ok := ctlMap[taskID]
50 | if !ok {
51 | return false
52 | }
53 | cancel()
54 | delete(ctlMap, taskID)
55 |
56 | return true
57 | }
58 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/tab.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/views/layout/mixin/ResizeHandler.js:
--------------------------------------------------------------------------------
1 | import store from '@/store'
2 |
3 | const { body } = document
4 | const WIDTH = 1024
5 | const RATIO = 3
6 |
7 | export default {
8 | watch: {
9 | $route(route) {
10 | if (this.device === 'mobile' && this.sidebar.opened) {
11 | store.dispatch('closeSideBar', { withoutAnimation: false })
12 | }
13 | }
14 | },
15 | beforeMount() {
16 | window.addEventListener('resize', this.resizeHandler)
17 | },
18 | mounted() {
19 | const isMobile = this.isMobile()
20 | if (isMobile) {
21 | store.dispatch('toggleDevice', 'mobile')
22 | store.dispatch('closeSideBar', { withoutAnimation: true })
23 | }
24 | },
25 | methods: {
26 | isMobile() {
27 | const rect = body.getBoundingClientRect()
28 | return rect.width - RATIO < WIDTH
29 | },
30 | resizeHandler() {
31 | if (!document.hidden) {
32 | const isMobile = this.isMobile()
33 | store.dispatch('toggleDevice', isMobile ? 'mobile' : 'desktop')
34 |
35 | if (isMobile) {
36 | store.dispatch('closeSideBar', { withoutAnimation: true })
37 | }
38 | }
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/nested.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/router/exportdb/get_list.go:
--------------------------------------------------------------------------------
1 | package exportdb
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/nange/gospider/web/core"
8 | "github.com/nange/gospider/web/model"
9 | log "github.com/sirupsen/logrus"
10 | )
11 |
12 | type GetExportDBListReq struct {
13 | Size int `json:"size" form:"size"`
14 | Offset int `json:"offset" form:"offset"`
15 | }
16 |
17 | type GetExportDBListResp struct {
18 | Total int `json:"total"`
19 | Data []model.ExportDB `json:"data"`
20 | }
21 |
22 | func GetExportDBList(c *gin.Context) {
23 | var req GetExportDBListReq
24 | if err := c.BindQuery(&req); err != nil {
25 | log.Warnf("query param is invalid")
26 | c.String(http.StatusBadRequest, "")
27 | return
28 | }
29 | log.Infof("get exportdb list req:%+v", req)
30 |
31 | exportdbs, count, err := model.GetExportDBList(core.GetGormDB(), req.Size, req.Offset)
32 | if err != nil {
33 | log.Errorf("GetExportDBList failed! err [%+v]", err)
34 | c.String(http.StatusInternalServerError, "")
35 | return
36 | }
37 | log.Infof("exportdbs:%+v", exportdbs)
38 |
39 | c.JSON(http.StatusOK, &GetExportDBListResp{
40 | Total: count,
41 | Data: exportdbs,
42 | })
43 | }
44 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/star.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/router/exportdb/create.go:
--------------------------------------------------------------------------------
1 | package exportdb
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/nange/gospider/web/core"
9 | "github.com/nange/gospider/web/model"
10 | log "github.com/sirupsen/logrus"
11 | )
12 |
13 | type CreateExportDBReq struct {
14 | model.ExportDB
15 | }
16 |
17 | type CreateExportDBResp struct {
18 | ID uint64 `json:"id"`
19 | CreateAt time.Time `json:"create_at"`
20 | }
21 |
22 | func CreateExportDB(c *gin.Context) {
23 | var req CreateExportDBReq
24 | if err := c.BindJSON(&req); err != nil {
25 | log.Errorf("bind json failed! err:%+v", err)
26 | c.String(http.StatusBadRequest, "")
27 | return
28 | }
29 | log.Infof("req:%+v", req)
30 |
31 | if req.Host == "" {
32 | req.Host = "127.0.0.1"
33 | }
34 | if req.Port == 0 {
35 | req.Port = 3306
36 | }
37 | if req.User == "" {
38 | req.User = "root"
39 | }
40 |
41 | db := core.GetGormDB()
42 | if err := req.Create(db); err != nil {
43 | log.Errorf("create sysdb err: %+v", err)
44 | c.AbortWithError(http.StatusInternalServerError, err)
45 | return
46 | }
47 |
48 | c.JSON(http.StatusOK, &CreateExportDBResp{
49 | ID: req.ID,
50 | CreateAt: req.CreatedAt,
51 | })
52 | }
53 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/password.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/utils/validate.js:
--------------------------------------------------------------------------------
1 | export function isvalidUsername(str) {
2 | const valid_map = ['admin', 'editor']
3 | return valid_map.indexOf(str.trim()) >= 0
4 | }
5 |
6 | /* 合法uri*/
7 | export function validateURL(textval) {
8 | const urlregex = /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/
9 | return urlregex.test(textval)
10 | }
11 |
12 | /* 小写字母*/
13 | export function validateLowerCase(str) {
14 | const reg = /^[a-z]+$/
15 | return reg.test(str)
16 | }
17 |
18 | /* 大写字母*/
19 | export function validateUpperCase(str) {
20 | const reg = /^[A-Z]+$/
21 | return reg.test(str)
22 | }
23 |
24 | /* 大小写字母*/
25 | export function validateAlphabets(str) {
26 | const reg = /^[A-Za-z]+$/
27 | return reg.test(str)
28 | }
29 |
30 | /**
31 | * validate email
32 | * @param email
33 | * @returns {boolean}
34 | */
35 | export function validateEmail(email) {
36 | const re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
37 | return re.test(email)
38 | }
39 |
--------------------------------------------------------------------------------
/web/static/src/api/task.js:
--------------------------------------------------------------------------------
1 | import request from '@/utils/request'
2 |
3 | // 数据列表
4 | export function fetchTaskList(params) {
5 | return request({
6 | url: '/api/tasks',
7 | method: 'get',
8 | params
9 | })
10 | }
11 |
12 | // 根据id查询数据
13 | export function getTask(id) {
14 | return request({
15 | url: '/api/tasks/' + id,
16 | method: 'get'
17 | })
18 | }
19 |
20 | // 查询所有rules
21 | export function getRules() {
22 | return request({
23 | url: '/api/rules',
24 | method: 'get'
25 | })
26 | }
27 |
28 | // 根据id停止任务
29 | export function stopTask(id) {
30 | return request({
31 | url: '/api/tasks/' + id + '/stop',
32 | method: 'put'
33 | })
34 | }
35 | // 根据id启动非定时任务
36 | export function startTask(id) {
37 | return request({
38 | url: '/api/tasks/' + id + '/start',
39 | method: 'put'
40 | })
41 | }
42 | // 根据id重启定时任务
43 | export function restartTask(id) {
44 | return request({
45 | url: '/api/tasks/' + id + '/restart',
46 | method: 'put'
47 | })
48 | }
49 | // 添加数据
50 | export function saveTask(data) {
51 | return request({
52 | url: '/api/tasks',
53 | method: 'post',
54 | data
55 | })
56 | }
57 |
58 | // 修改数据
59 | export function updateTask(id, data) {
60 | return request({
61 | url: '/api/tasks/' + id,
62 | method: 'put',
63 | data
64 | })
65 | }
66 |
67 |
--------------------------------------------------------------------------------
/web/static/src/filters/index.js:
--------------------------------------------------------------------------------
1 | // set function parseTime,formatTime to filter
2 | export { parseTime, formatTime } from '@/utils'
3 |
4 | function pluralize(time, label) {
5 | if (time === 1) {
6 | return time + label
7 | }
8 | return time + label + 's'
9 | }
10 |
11 | export function timeAgo(time) {
12 | const between = Date.now() / 1000 - Number(time)
13 | if (between < 3600) {
14 | return pluralize(~~(between / 60), ' minute')
15 | } else if (between < 86400) {
16 | return pluralize(~~(between / 3600), ' hour')
17 | } else {
18 | return pluralize(~~(between / 86400), ' day')
19 | }
20 | }
21 |
22 | /* 数字 格式化*/
23 | export function numberFormatter(num, digits) {
24 | const si = [
25 | { value: 1E18, symbol: 'E' },
26 | { value: 1E15, symbol: 'P' },
27 | { value: 1E12, symbol: 'T' },
28 | { value: 1E9, symbol: 'G' },
29 | { value: 1E6, symbol: 'M' },
30 | { value: 1E3, symbol: 'k' }
31 | ]
32 | for (let i = 0; i < si.length; i++) {
33 | if (num >= si[i].value) {
34 | return (num / si[i].value + 0.1).toFixed(digits).replace(/\.0+$|(\.[0-9]*[1-9])0+$/, '$1') + si[i].symbol
35 | }
36 | }
37 | return num.toString()
38 | }
39 |
40 | export function toThousandslsFilter(num) {
41 | return (+num || 0).toString().replace(/^-?\d+/g, m => m.replace(/(?=(?!\b)(\d{3})+$)/g, ','))
42 | }
43 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/clipboard.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/peoples.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/router/rule/get_rules.go:
--------------------------------------------------------------------------------
1 | package rule
2 |
3 | import (
4 | "bytes"
5 | "io/ioutil"
6 | "net/http"
7 | "sort"
8 |
9 | "github.com/gin-gonic/gin"
10 | log "github.com/sirupsen/logrus"
11 | "golang.org/x/text/encoding/simplifiedchinese"
12 | "golang.org/x/text/transform"
13 |
14 | "github.com/nange/gospider/spider"
15 | )
16 |
17 | type GetRuleListResp struct {
18 | Total int `json:"total"`
19 | Data []string `json:"data"`
20 | }
21 |
22 | func GetRuleList(c *gin.Context) {
23 | keys := spider.GetTaskRuleKeys()
24 | if len(keys) == 0 {
25 | log.Warnf("task rule is empty")
26 | } else {
27 | sort.Sort(Pinyin(keys))
28 | }
29 |
30 | c.JSON(http.StatusOK, &GetRuleListResp{
31 | Total: len(keys),
32 | Data: keys,
33 | })
34 | }
35 |
36 | type Pinyin []string
37 |
38 | func (s Pinyin) Len() int { return len(s) }
39 | func (s Pinyin) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
40 | func (s Pinyin) Less(i, j int) bool {
41 | a, _ := UTF82GB18030(s[i])
42 | b, _ := UTF82GB18030(s[j])
43 | bLen := len(b)
44 | for idx, chr := range a {
45 | if idx > bLen-1 {
46 | return false
47 | }
48 | if chr != b[idx] {
49 | return chr < b[idx]
50 | }
51 | }
52 | return true
53 | }
54 |
55 | func UTF82GB18030(src string) ([]byte, error) {
56 | GB18030 := simplifiedchinese.All[0]
57 | return ioutil.ReadAll(transform.NewReader(bytes.NewReader([]byte(src)), GB18030.NewEncoder()))
58 | }
59 |
--------------------------------------------------------------------------------
/web/core/db_test.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | sqlmock "github.com/DATA-DOG/go-sqlmock"
8 | "github.com/jinzhu/gorm"
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | type testModel struct {
14 | ID uint64 `gorm:"column:id;type:bigint unsigned AUTO_INCREMENT;primary_key"`
15 | Name string `gorm:"column:user_name;type:varchar(32) not null"`
16 | }
17 |
18 | func (t *testModel) TableName() string {
19 | return "gospider_testmodel"
20 | }
21 |
22 | func TestRegisterAndAutoMigrate(t *testing.T) {
23 | assert.Panics(t, func() {
24 | Register(nil)
25 | })
26 | assert.Panics(t, func() {
27 | var tm *testModel
28 | Register(tm)
29 | })
30 |
31 | Register(&testModel{})
32 | assert.Panics(t, func() {
33 | Register(&testModel{})
34 | })
35 |
36 | db, mock, err := sqlmock.New()
37 | require.NoError(t, err)
38 | defer db.Close()
39 | gdb, err := gorm.Open("mysql", db)
40 | require.NoError(t, err)
41 | SetGormDB(gdb)
42 |
43 | mock.ExpectExec("(?i)create table `gospider_testmodel`").
44 | WillReturnResult(sqlmock.NewResult(0, 0))
45 |
46 | assert.NoError(t, AutoMigrate())
47 | assert.NoError(t, mock.ExpectationsWereMet())
48 |
49 | mock.ExpectExec("(?i)create table `gospider_testmodel`").
50 | WillReturnError(fmt.Errorf("some error"))
51 | assert.NotNil(t, AutoMigrate())
52 | assert.NoError(t, mock.ExpectationsWereMet())
53 |
54 | }
55 |
--------------------------------------------------------------------------------
/spider/task_rule_test.go:
--------------------------------------------------------------------------------
1 | package spider
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/pkg/errors"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestTaskRule(t *testing.T) {
11 | r1, err := GetTaskRule("test_rule_not_exist")
12 | assert.Nil(t, r1)
13 | assert.Equal(t, ErrTaskRuleNotExist, errors.Cause(err))
14 |
15 | assert.Panics(t, func() {
16 | Register(nil)
17 | })
18 | assert.Panics(t, func() {
19 | Register(&TaskRule{})
20 | })
21 | assert.Panics(t, func() {
22 | Register(&TaskRule{Namespace: ""})
23 | })
24 | assert.Panics(t, func() {
25 | Register(&TaskRule{Namespace: ""})
26 | })
27 | assert.Panics(t, func() {
28 | Register(&TaskRule{
29 | Name: "test",
30 | Rule: &Rule{},
31 | })
32 | })
33 | assert.Panics(t, func() {
34 | Register(&TaskRule{
35 | Name: "test",
36 | Rule: &Rule{
37 | Head: func(ctx *Context) error {
38 | return nil
39 | },
40 | Nodes: map[int]*Node{
41 | 0: {},
42 | 2: {},
43 | },
44 | },
45 | })
46 | })
47 |
48 | Register(&TaskRule{
49 | Name: "test_rule_name",
50 | Namespace: "test_namespace",
51 | OutputFields: []string{"test_field"},
52 | Rule: &Rule{
53 | Head: func(ctx *Context) error {
54 | return nil
55 | },
56 | Nodes: map[int]*Node{
57 | 0: {},
58 | },
59 | },
60 | })
61 | r2, err := GetTaskRule("test_rule_name")
62 | assert.NoError(t, err)
63 | assert.NotNil(t, r2)
64 |
65 | keys := GetTaskRuleKeys()
66 | assert.Contains(t, keys, "test_rule_name")
67 | }
68 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/language.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/web/static/src/styles/element-ui.scss:
--------------------------------------------------------------------------------
1 | //覆盖一些element-ui样式
2 |
3 | .el-breadcrumb__inner, .el-breadcrumb__inner a{
4 | font-weight: 400!important;
5 | }
6 |
7 | .el-upload {
8 | input[type="file"] {
9 | display: none !important;
10 | }
11 | }
12 |
13 | .el-upload__input {
14 | display: none;
15 | }
16 |
17 | .cell {
18 | .el-tag {
19 | margin-right: 0px;
20 | }
21 | }
22 |
23 | .small-padding {
24 | .cell {
25 | padding-left: 5px;
26 | padding-right: 5px;
27 | }
28 | }
29 |
30 | .fixed-width{
31 | .el-button--mini{
32 | padding: 7px 10px;
33 | width: 60px;
34 | }
35 | }
36 |
37 | .status-col {
38 | .cell {
39 | padding: 0 10px;
40 | text-align: center;
41 | .el-tag {
42 | margin-right: 0px;
43 | }
44 | }
45 | }
46 |
47 | //暂时性解决dialog 问题 https://github.com/ElemeFE/element/issues/2461
48 | .el-dialog {
49 | transform: none;
50 | left: 0;
51 | position: relative;
52 | margin: 0 auto;
53 | }
54 |
55 | //文章页textarea修改样式
56 | .article-textarea {
57 | textarea {
58 | padding-right: 40px;
59 | resize: none;
60 | border: none;
61 | border-radius: 0px;
62 | border-bottom: 1px solid #bfcbd9;
63 | }
64 | }
65 |
66 | //element ui upload
67 | .upload-container {
68 | .el-upload {
69 | width: 100%;
70 | .el-upload-dragger {
71 | width: 100%;
72 | height: 200px;
73 | }
74 | }
75 | }
76 |
77 | //dropdown
78 | .el-dropdown-menu{
79 | a{
80 | display: block
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/web/model/task_test.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/DATA-DOG/go-sqlmock"
7 | )
8 |
9 | func (s *testModelSuite) TestGetTaskList() {
10 | rows1 := sqlmock.NewRows([]string{"count(*)"}).AddRow(10)
11 | s.mock.ExpectQuery("(?i)select count\\(\\*\\) from `gospider_task`").
12 | WillReturnRows(rows1)
13 | rows2 := sqlmock.NewRows([]string{"id"}).
14 | AddRow(uint64(1)).
15 | AddRow(uint64(2)).
16 | AddRow(uint64(3)).
17 | AddRow(uint64(4)).
18 | AddRow(uint64(5))
19 | s.mock.ExpectQuery("(?i)select \\* from `gospider_task`").
20 | WillReturnRows(rows2)
21 |
22 | list, count, err := GetTaskList(s.gdb, 5, 0)
23 | s.Require().NoError(err)
24 | s.Equal(10, count)
25 | s.Equal(5, len(list))
26 | s.Equal(uint64(1), list[0].ID)
27 | s.NoError(s.mock.ExpectationsWereMet())
28 |
29 | s.mock.ExpectQuery("(?i)select count\\(\\*\\) from `gospider_task`").
30 | WillReturnError(fmt.Errorf("some error"))
31 | list, count, err = GetTaskList(s.gdb, 5, 0)
32 | s.Require().NotNil(err)
33 | s.Equal(0, count)
34 | s.Nil(list)
35 | s.NoError(s.mock.ExpectationsWereMet())
36 |
37 | rows3 := sqlmock.NewRows([]string{"count(*)"}).AddRow(10)
38 | s.mock.ExpectQuery("(?i)select count\\(\\*\\) from `gospider_task`").
39 | WillReturnRows(rows3)
40 | s.mock.ExpectQuery("(?i)select \\* from `gospider_task` order by").
41 | WillReturnError(fmt.Errorf("some error"))
42 | list2, count2, err2 := GetTaskList(s.gdb, 5, 0)
43 | s.Require().NotNil(err2)
44 | s.Equal(0, count2)
45 | s.Nil(list2)
46 | s.NoError(s.mock.ExpectationsWereMet())
47 | }
48 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/404.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/styles/mixin.scss:
--------------------------------------------------------------------------------
1 | @mixin clearfix {
2 | &:after {
3 | content: "";
4 | display: table;
5 | clear: both;
6 | }
7 | }
8 |
9 | @mixin scrollBar {
10 | &::-webkit-scrollbar-track-piece {
11 | background: #d3dce6;
12 | }
13 | &::-webkit-scrollbar {
14 | width: 6px;
15 | }
16 | &::-webkit-scrollbar-thumb {
17 | background: #99a9bf;
18 | border-radius: 20px;
19 | }
20 | }
21 |
22 | @mixin relative {
23 | position: relative;
24 | width: 100%;
25 | height: 100%;
26 | }
27 |
28 | @mixin pct($pct) {
29 | width: #{$pct};
30 | position: relative;
31 | margin: 0 auto;
32 | }
33 |
34 | @mixin triangle($width, $height, $color, $direction) {
35 | $width: $width/2;
36 | $color-border-style: $height solid $color;
37 | $transparent-border-style: $width solid transparent;
38 | height: 0;
39 | width: 0;
40 | @if $direction==up {
41 | border-bottom: $color-border-style;
42 | border-left: $transparent-border-style;
43 | border-right: $transparent-border-style;
44 | }
45 | @else if $direction==right {
46 | border-left: $color-border-style;
47 | border-top: $transparent-border-style;
48 | border-bottom: $transparent-border-style;
49 | }
50 | @else if $direction==down {
51 | border-top: $color-border-style;
52 | border-left: $transparent-border-style;
53 | border-right: $transparent-border-style;
54 | }
55 | @else if $direction==left {
56 | border-right: $color-border-style;
57 | border-top: $transparent-border-style;
58 | border-bottom: $transparent-border-style;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/web/static/src/store/modules/app.js:
--------------------------------------------------------------------------------
1 | import Cookies from 'js-cookie'
2 | import { getBrowserLanguage } from '../../lang'
3 |
4 | const app = {
5 | state: {
6 | sidebar: {
7 | opened: !+Cookies.get('sidebarStatus'),
8 | withoutAnimation: false
9 | },
10 | device: 'desktop',
11 | language: Cookies.get('language') || getBrowserLanguage()
12 | },
13 | mutations: {
14 | TOGGLE_SIDEBAR: state => {
15 | if (state.sidebar.opened) {
16 | Cookies.set('sidebarStatus', 1)
17 | } else {
18 | Cookies.set('sidebarStatus', 0)
19 | }
20 | state.sidebar.opened = !state.sidebar.opened
21 | state.sidebar.withoutAnimation = false
22 | },
23 | CLOSE_SIDEBAR: (state, withoutAnimation) => {
24 | Cookies.set('sidebarStatus', 1)
25 | state.sidebar.opened = false
26 | state.sidebar.withoutAnimation = withoutAnimation
27 | },
28 | TOGGLE_DEVICE: (state, device) => {
29 | state.device = device
30 | },
31 | SET_LANGUAGE: (state, language) => {
32 | state.language = language
33 | Cookies.set('language', language)
34 | }
35 | },
36 | actions: {
37 | toggleSideBar({ commit }) {
38 | commit('TOGGLE_SIDEBAR')
39 | },
40 | closeSideBar({ commit }, { withoutAnimation }) {
41 | commit('CLOSE_SIDEBAR', withoutAnimation)
42 | },
43 | toggleDevice({ commit }, device) {
44 | commit('TOGGLE_DEVICE', device)
45 | },
46 | setLanguage({ commit }, language) {
47 | commit('SET_LANGUAGE', language)
48 | }
49 | }
50 | }
51 |
52 | export default app
53 |
--------------------------------------------------------------------------------
/web/model/exportdb_test.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/DATA-DOG/go-sqlmock"
7 | )
8 |
9 | func (s *testModelSuite) TestGetExportDBList() {
10 | rows1 := sqlmock.NewRows([]string{"count(*)"}).AddRow(10)
11 | s.mock.ExpectQuery("(?i)select count\\(\\*\\) from `gospider_exportdb`").
12 | WillReturnRows(rows1)
13 | rows2 := sqlmock.NewRows([]string{"id"}).
14 | AddRow(uint64(1)).
15 | AddRow(uint64(2)).
16 | AddRow(uint64(3)).
17 | AddRow(uint64(4)).
18 | AddRow(uint64(5))
19 | s.mock.ExpectQuery("(?i)select \\* from `gospider_exportdb`").
20 | WillReturnRows(rows2)
21 |
22 | list, count, err := GetExportDBList(s.gdb, 5, 0)
23 | s.Require().NoError(err)
24 | s.Equal(10, count)
25 | s.Equal(5, len(list))
26 | s.Equal(uint64(1), list[0].ID)
27 | s.NoError(s.mock.ExpectationsWereMet())
28 |
29 | s.mock.ExpectQuery("(?i)select count\\(\\*\\) from `gospider_exportdb`").
30 | WillReturnError(fmt.Errorf("some error"))
31 | list, count, err = GetExportDBList(s.gdb, 5, 0)
32 | s.Require().NotNil(err)
33 | s.Equal(0, count)
34 | s.Nil(list)
35 | s.NoError(s.mock.ExpectationsWereMet())
36 |
37 | rows3 := sqlmock.NewRows([]string{"count(*)"}).AddRow(10)
38 | s.mock.ExpectQuery("(?i)select count\\(\\*\\) from `gospider_exportdb`").
39 | WillReturnRows(rows3)
40 | s.mock.ExpectQuery("(?i)select \\* from `gospider_exportdb` order by").
41 | WillReturnError(fmt.Errorf("some error"))
42 | list2, count2, err2 := GetExportDBList(s.gdb, 5, 0)
43 | s.Require().NotNil(err2)
44 | s.Equal(0, count2)
45 | s.Nil(list2)
46 | s.NoError(s.mock.ExpectationsWereMet())
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/web/router/task/stop_task.go:
--------------------------------------------------------------------------------
1 | package task
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/nange/gospider/common"
9 | "github.com/nange/gospider/spider"
10 | "github.com/nange/gospider/web/core"
11 | "github.com/nange/gospider/web/model"
12 | "github.com/nange/gospider/web/service"
13 | "github.com/pkg/errors"
14 | log "github.com/sirupsen/logrus"
15 | )
16 |
17 | // 根据任务id停止任务
18 | func StopTask(c *gin.Context) {
19 | taskIDStr := c.Param("id")
20 | if taskIDStr == "" {
21 | log.Warnf("StopTaskReq taskID is empty")
22 | c.AbortWithStatus(http.StatusBadRequest)
23 | return
24 | }
25 | taskID, err := strconv.ParseUint(taskIDStr, 10, 64)
26 | if err != nil {
27 | log.Warnf("StopTaskReq taskID format is invalid, taskID:%v", taskIDStr)
28 | c.AbortWithStatus(http.StatusBadRequest)
29 | return
30 | }
31 | log.Infof("StopTaskReq:%+v", taskID)
32 |
33 | if taskLock.IsRunning(taskID) {
34 | c.String(http.StatusConflict, "other operation is running")
35 | return
36 | }
37 | defer taskLock.Complete(taskID)
38 |
39 | // stop cron task
40 | if ct := service.GetCronTask(taskID); ct != nil {
41 | ct.Stop()
42 | }
43 |
44 | // cancel spider task
45 | spider.CancelTask(taskID)
46 |
47 | // set task status to TaskStatusStopped
48 | err = model.NewTaskQuerySet(core.GetGormDB()).IDEq(taskID).GetUpdater().SetStatus(common.TaskStatusStopped).Update()
49 | if err != nil {
50 | log.Errorf("update task status err:%+v", errors.WithStack(err))
51 | c.String(http.StatusInternalServerError, "")
52 | return
53 | }
54 |
55 | c.String(http.StatusOK, "success")
56 | }
57 |
--------------------------------------------------------------------------------
/web/static/src/components/Breadcrumb/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{generateTitle(item.meta.title)}}
6 | {{generateTitle(item.meta.title)}}
7 |
8 |
9 |
10 |
11 |
12 |
42 |
43 |
55 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/shoppingCard.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/build/check-versions.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const chalk = require('chalk')
3 | const semver = require('semver')
4 | const packageConfig = require('../package.json')
5 | const shell = require('shelljs')
6 |
7 | function exec(cmd) {
8 | return require('child_process')
9 | .execSync(cmd)
10 | .toString()
11 | .trim()
12 | }
13 |
14 | const versionRequirements = [
15 | {
16 | name: 'node',
17 | currentVersion: semver.clean(process.version),
18 | versionRequirement: packageConfig.engines.node
19 | }
20 | ]
21 |
22 | if (shell.which('npm')) {
23 | versionRequirements.push({
24 | name: 'npm',
25 | currentVersion: exec('npm --version'),
26 | versionRequirement: packageConfig.engines.npm
27 | })
28 | }
29 |
30 | module.exports = function() {
31 | const warnings = []
32 |
33 | for (let i = 0; i < versionRequirements.length; i++) {
34 | const mod = versionRequirements[i]
35 |
36 | if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) {
37 | warnings.push(
38 | mod.name +
39 | ': ' +
40 | chalk.red(mod.currentVersion) +
41 | ' should be ' +
42 | chalk.green(mod.versionRequirement)
43 | )
44 | }
45 | }
46 |
47 | if (warnings.length) {
48 | console.log('')
49 | console.log(
50 | chalk.yellow(
51 | 'To use this template, you must update following to modules:'
52 | )
53 | )
54 | console.log()
55 |
56 | for (let i = 0; i < warnings.length; i++) {
57 | const warning = warnings[i]
58 | console.log(' ' + warning)
59 | }
60 |
61 | console.log()
62 | process.exit(1)
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/web/static/src/store/modules/permission.js:
--------------------------------------------------------------------------------
1 | import { asyncRouterMap, constantRouterMap } from '@/router'
2 |
3 | /**
4 | * 通过meta.role判断是否与当前用户权限匹配
5 | * @param roles
6 | * @param route
7 | */
8 | function hasPermission(roles, route) {
9 | if (route.meta && route.meta.roles) {
10 | return roles.some(role => route.meta.roles.indexOf(role) >= 0)
11 | } else {
12 | return true
13 | }
14 | }
15 |
16 | /**
17 | * 递归过滤异步路由表,返回符合用户角色权限的路由表
18 | * @param asyncRouterMap
19 | * @param roles
20 | */
21 | function filterAsyncRouter(asyncRouterMap, roles) {
22 | const accessedRouters = asyncRouterMap.filter(route => {
23 | if (hasPermission(roles, route)) {
24 | if (route.children && route.children.length) {
25 | route.children = filterAsyncRouter(route.children, roles)
26 | }
27 | return true
28 | }
29 | return false
30 | })
31 | return accessedRouters
32 | }
33 |
34 | const permission = {
35 | state: {
36 | routers: constantRouterMap,
37 | addRouters: []
38 | },
39 | mutations: {
40 | SET_ROUTERS: (state, routers) => {
41 | state.addRouters = routers
42 | state.routers = constantRouterMap.concat(routers)
43 | }
44 | },
45 | actions: {
46 | GenerateRoutes({ commit }, data) {
47 | return new Promise(resolve => {
48 | const { roles } = data
49 | let accessedRouters
50 | if (roles.indexOf('admin') >= 0) {
51 | accessedRouters = asyncRouterMap
52 | } else {
53 | accessedRouters = filterAsyncRouter(asyncRouterMap, roles)
54 | }
55 | commit('SET_ROUTERS', accessedRouters)
56 | resolve()
57 | })
58 | }
59 | }
60 | }
61 |
62 | export default permission
63 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/bug.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/people.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/directive/waves/waves.js:
--------------------------------------------------------------------------------
1 | import './waves.css'
2 |
3 | export default{
4 | bind(el, binding) {
5 | el.addEventListener('click', e => {
6 | const customOpts = Object.assign({}, binding.value)
7 | const opts = Object.assign({
8 | ele: el, // 波纹作用元素
9 | type: 'hit', // hit点击位置扩散center中心点扩展
10 | color: 'rgba(0, 0, 0, 0.15)' // 波纹颜色
11 | }, customOpts)
12 | const target = opts.ele
13 | if (target) {
14 | target.style.position = 'relative'
15 | target.style.overflow = 'hidden'
16 | const rect = target.getBoundingClientRect()
17 | let ripple = target.querySelector('.waves-ripple')
18 | if (!ripple) {
19 | ripple = document.createElement('span')
20 | ripple.className = 'waves-ripple'
21 | ripple.style.height = ripple.style.width = Math.max(rect.width, rect.height) + 'px'
22 | target.appendChild(ripple)
23 | } else {
24 | ripple.className = 'waves-ripple'
25 | }
26 | switch (opts.type) {
27 | case 'center':
28 | ripple.style.top = (rect.height / 2 - ripple.offsetHeight / 2) + 'px'
29 | ripple.style.left = (rect.width / 2 - ripple.offsetWidth / 2) + 'px'
30 | break
31 | default:
32 | ripple.style.top = (e.pageY - rect.top - ripple.offsetHeight / 2 - document.documentElement.scrollTop || document.body.scrollTop) + 'px'
33 | ripple.style.left = (e.pageX - rect.left - ripple.offsetWidth / 2 - document.documentElement.scrollLeft || document.body.scrollLeft) + 'px'
34 | }
35 | ripple.style.backgroundColor = opts.color
36 | ripple.className = 'waves-ripple z-active'
37 | return false
38 | }
39 | }, false)
40 | }
41 | }
42 |
43 |
--------------------------------------------------------------------------------
/common/enu.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "strings"
7 | )
8 |
9 | // TaskStatus the task status type define
10 | type TaskStatus uint8
11 |
12 | const (
13 | TaskStatusUnknown TaskStatus = iota
14 | TaskStatusRunning
15 | TaskStatusPaused
16 | TaskStatusStopped
17 | TaskStatusUnexceptedExited
18 | TaskStatusCompleted
19 | TaskStatusRunningTimeout
20 | )
21 |
22 | var tsMap = map[TaskStatus]string{
23 | TaskStatusUnknown: "未知状态",
24 | TaskStatusRunning: "运行中",
25 | TaskStatusPaused: "暂停",
26 | TaskStatusStopped: "停止",
27 | TaskStatusUnexceptedExited: "异常退出",
28 | TaskStatusCompleted: "完成",
29 | TaskStatusRunningTimeout: "运行超时",
30 | }
31 |
32 | var InvalidTaskStatus = errors.New("invalid task status")
33 |
34 | func (ts TaskStatus) String() string {
35 | s, ok := tsMap[ts]
36 | if !ok {
37 | panic(fmt.Sprintf("unexcepted TaskStatus %d", ts))
38 | }
39 | return s
40 | }
41 |
42 | func ParseTaskStatusFromString(s string) (TaskStatus, error) {
43 | switch s {
44 | case "未知状态":
45 | return TaskStatusUnknown, nil
46 | case "运行中":
47 | return TaskStatusRunning, nil
48 | case "暂停":
49 | return TaskStatusPaused, nil
50 | case "停止":
51 | return TaskStatusStopped, nil
52 | case "异常退出":
53 | return TaskStatusUnexceptedExited, nil
54 | case "完成":
55 | return TaskStatusCompleted, nil
56 | case "运行超时":
57 | return TaskStatusRunningTimeout, nil
58 | }
59 | return TaskStatusUnknown, InvalidTaskStatus
60 | }
61 |
62 | func (ts TaskStatus) MarshalJSON() ([]byte, error) {
63 | return []byte("\"" + ts.String() + "\""), nil
64 | }
65 |
66 | func (ts *TaskStatus) UnmarshalJSON(data []byte) (err error) {
67 | s := strings.Trim(strings.ToUpper(string(data)), "\"")
68 | *ts, err = ParseTaskStatusFromString(s)
69 | return
70 | }
71 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/nange/gospider
2 |
3 | require (
4 | cloud.google.com/go v0.26.0 // indirect
5 | github.com/DATA-DOG/go-sqlmock v1.3.2
6 | github.com/PuerkitoBio/goquery v1.4.1
7 | github.com/andybalholm/cascadia v1.0.0 // indirect
8 | github.com/antchfx/htmlquery v0.0.0-20180524052033-b4407197cfe8 // indirect
9 | github.com/antchfx/xmlquery v0.0.0-20180524052823-9188d8442369 // indirect
10 | github.com/antchfx/xpath v0.0.0-20180524052354-077bca4d2caa // indirect
11 | github.com/appleboy/gin-jwt/v2 v2.6.2
12 | github.com/denisenkom/go-mssqldb v0.0.0-20180824013952-8fac8b954edb // indirect
13 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
14 | github.com/gin-contrib/sse v0.1.0 // indirect
15 | github.com/gin-gonic/gin v1.4.0
16 | github.com/go-sql-driver/mysql v1.4.1
17 | github.com/go-xorm/builder v0.3.4
18 | github.com/gobuffalo/packr v1.26.0
19 | github.com/gobwas/glob v0.2.3 // indirect
20 | github.com/gocolly/colly v1.2.0
21 | github.com/google/go-cmp v0.2.0 // indirect
22 | github.com/jinzhu/gorm v1.9.1
23 | github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect
24 | github.com/jinzhu/now v0.0.0-20180511015916-ed742868f2ae // indirect
25 | github.com/kennygrant/sanitize v1.2.3 // indirect
26 | github.com/pkg/errors v0.8.1
27 | github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967
28 | github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca // indirect
29 | github.com/sirupsen/logrus v1.4.2
30 | github.com/stretchr/testify v1.3.0
31 | github.com/temoto/robotstxt v0.0.0-20170603013557-9e4646fa7053 // indirect
32 | github.com/ugorji/go/codec v1.1.7 // indirect
33 | golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf
34 | golang.org/x/text v0.3.0
35 | google.golang.org/appengine v1.1.0 // indirect
36 | )
37 |
38 | go 1.13
39 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/table.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/styles/btn.scss:
--------------------------------------------------------------------------------
1 | @import './variables.scss';
2 |
3 | @mixin colorBtn($color) {
4 | background: $color;
5 | &:hover {
6 | color: $color;
7 | &:before,
8 | &:after {
9 | background: $color;
10 | }
11 | }
12 | }
13 |
14 | .blue-btn {
15 | @include colorBtn($blue)
16 | }
17 |
18 | .light-blue-btn {
19 | @include colorBtn($light-blue)
20 | }
21 |
22 | .red-btn {
23 | @include colorBtn($red)
24 | }
25 |
26 | .pink-btn {
27 | @include colorBtn($pink)
28 | }
29 |
30 | .green-btn {
31 | @include colorBtn($green)
32 | }
33 |
34 | .tiffany-btn {
35 | @include colorBtn($tiffany)
36 | }
37 |
38 | .yellow-btn {
39 | @include colorBtn($yellow)
40 | }
41 |
42 | .pan-btn {
43 | font-size: 14px;
44 | color: #fff;
45 | padding: 14px 36px;
46 | border-radius: 8px;
47 | border: none;
48 | outline: none;
49 | margin-right: 25px;
50 | transition: 600ms ease all;
51 | position: relative;
52 | display: inline-block;
53 | &:hover {
54 | background: #fff;
55 | &:before,
56 | &:after {
57 | width: 100%;
58 | transition: 600ms ease all;
59 | }
60 | }
61 | &:before,
62 | &:after {
63 | content: '';
64 | position: absolute;
65 | top: 0;
66 | right: 0;
67 | height: 2px;
68 | width: 0;
69 | transition: 400ms ease all;
70 | }
71 | &::after {
72 | right: inherit;
73 | top: inherit;
74 | left: 0;
75 | bottom: 0;
76 | }
77 | }
78 |
79 | .custom-button {
80 | display: inline-block;
81 | line-height: 1;
82 | white-space: nowrap;
83 | cursor: pointer;
84 | background: #fff;
85 | color: #fff;
86 | -webkit-appearance: none;
87 | text-align: center;
88 | box-sizing: border-box;
89 | outline: 0;
90 | margin: 0;
91 | padding: 10px 15px;
92 | font-size: 14px;
93 | border-radius: 4px;
94 | }
95 |
96 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/eye.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/model/exportdb.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/jinzhu/gorm"
7 | "github.com/nange/gospider/web/core"
8 | "github.com/pkg/errors"
9 | )
10 |
11 | //go:generate goqueryset -in exportdb.go
12 | // gen:qs
13 | type ExportDB struct {
14 | ID uint64 `json:"id" gorm:"column:id;type:bigint unsigned AUTO_INCREMENT;primary_key"`
15 | ShowName string `json:"show_name" gorm:"column:show_name;type:varchar(64);not null;unique_index:uk_show_name"`
16 | Host string `json:"host" gorm:"column:host;type:varchar(128);not null"`
17 | Port int `json:"port" gorm:"column:port;type:int;not null"`
18 | User string `json:"user" gorm:"column:user;type:varchar(32);not null"`
19 | Password string `json:"password" gorm:"column:password;type:varchar(32);not null;default:''"`
20 | DBName string `json:"db_name" gorm:"column:db_name;type:varchar(64);not null"`
21 | CreatedAt time.Time `json:"created_at" gorm:"column:created_at;type:datetime;not null;default:CURRENT_TIMESTAMP;index:idx_created_at"`
22 | UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at;type:datetime;not null;default:CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"`
23 | }
24 |
25 | func (o *ExportDB) TableName() string {
26 | return "gospider_exportdb"
27 | }
28 |
29 | func init() {
30 | core.Register(&ExportDB{})
31 | }
32 |
33 | func GetExportDBList(db *gorm.DB, size, offset int) ([]ExportDB, int, error) {
34 | queryset := NewExportDBQuerySet(db)
35 | count, err := queryset.Count()
36 | if err != nil {
37 | return nil, 0, errors.WithStack(err)
38 | }
39 |
40 | queryset = NewExportDBQuerySet(db.Limit(size).Offset(offset))
41 | ret := make([]ExportDB, 0)
42 | if err := queryset.OrderDescByCreatedAt().All(&ret); err != nil {
43 | return nil, 0, errors.WithStack(err)
44 | }
45 |
46 | return ret, count, nil
47 | }
48 |
--------------------------------------------------------------------------------
/web/static/src/views/layout/Layout.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
49 |
50 |
72 |
--------------------------------------------------------------------------------
/web/static/src/lang/zh.js:
--------------------------------------------------------------------------------
1 | export default {
2 | route: {
3 | dashboard: '首页',
4 | icons: '图标',
5 | errorPages: '错误页面',
6 | page401: '401',
7 | page404: '404',
8 | errorLog: '错误日志',
9 | theme: '换肤',
10 | i18n: '国际化',
11 | taskManage: '任务管理',
12 | createTask: '创建任务',
13 | editTask: '修改任务',
14 | taskList: '任务列表',
15 | dbManage: '数据库管理',
16 | addExpDb: '添加导出数据库',
17 | expDbList: '导出数据库列表'
18 | },
19 | task: {
20 | name: '任务名称',
21 | rule: '任务规则名',
22 | desc: '任务描述',
23 | cron: '定时执行',
24 | proxy: '代理列表',
25 | agent: '用户代理',
26 | maxDepth: '爬虫最大深度',
27 | allowDomains: '允许访问的域名',
28 | urlFilter: 'URL过滤',
29 | maxBody: '最大body值',
30 | requestTimeout: '请求超时时间',
31 | outType: '导出类型',
32 | autoMigrate: '自动建表',
33 | limitEn: '频率限制',
34 | limitDomainGlob: '域名glob匹配',
35 | limitDelay: '延迟',
36 | limitRandomDelay: '随机延迟',
37 | limitPara: '请求并发度',
38 | add: '创建任务',
39 | update: '更新任务',
40 | id: '序号',
41 | status: '状态',
42 | counts: '运行次数',
43 | iscron: '定时任务',
44 | create_at: '创建时间',
45 | actions: '操作',
46 | info: '详情',
47 | edit: '修改',
48 | stop: '停止',
49 | start: '启动',
50 | restart: '重启'
51 | },
52 | exportdb: {
53 | add: '新建数据库',
54 | id: '序号',
55 | showname: '显示名称',
56 | host: '主机地址',
57 | port: '端口',
58 | user: '用户名',
59 | password: '密码',
60 | dbname: '数据库名',
61 | actions: '操作',
62 | edit: '修改',
63 | delete: '删除'
64 | },
65 | navbar: {
66 | logOut: '退出登录',
67 | dashboard: '首页',
68 | screenfull: '全屏',
69 | theme: '换肤'
70 | },
71 | login: {
72 | title: '系统登录',
73 | logIn: '登录',
74 | username: '账号',
75 | password: '密码'
76 | },
77 | tagsView: {
78 | close: '关闭',
79 | closeOthers: '关闭其它',
80 | closeAll: '关闭所有'
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/international.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/model/user_test.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "github.com/DATA-DOG/go-sqlmock"
5 | )
6 |
7 | func (s *testModelSuite) TestGenUserHashPassword() {
8 | hash, err := GenUserHashPassword("admin")
9 | s.NoErrorf(err, "should gen hash password success")
10 | s.T().Logf("hash:%s", hash)
11 | }
12 |
13 | func (s *testModelSuite) TestInitAdminUserIfNeeded() {
14 | row := sqlmock.NewRows([]string{"id"}).AddRow(uint64(1))
15 | s.mock.ExpectQuery("(?i)select \\* from `gospider_user` where \\(user_name = \\?\\)").
16 | WithArgs("admin").
17 | WillReturnRows(row)
18 | err := InitAdminUserIfNeeded(s.gdb)
19 | s.NoError(err)
20 | s.NoError(s.mock.ExpectationsWereMet())
21 |
22 | s.mock.ExpectQuery("(?i)select \\* from `gospider_user` where \\(user_name = \\?\\)").
23 | WithArgs("admin").
24 | WillReturnRows(sqlmock.NewRows([]string{"id"}))
25 | s.mock.ExpectExec("(?i)insert into `gospider_user` (.+) values").
26 | WillReturnResult(sqlmock.NewResult(1, 1))
27 | err = InitAdminUserIfNeeded(s.gdb)
28 | s.NoError(err)
29 | s.NoError(s.mock.ExpectationsWereMet())
30 |
31 | }
32 |
33 | func (s *testModelSuite) TestIsValidUser() {
34 | pw, err := GenUserHashPassword("admin")
35 | s.Require().NoError(err)
36 |
37 | s.mock.ExpectQuery("(?i)select \\* from `gospider_user` where \\(user_name = \\?\\)").
38 | WithArgs("admin").
39 | WillReturnRows(sqlmock.NewRows([]string{"id"}))
40 | valid, user, err := IsValidUser(s.gdb, "admin", pw)
41 | s.Nil(err)
42 | s.False(valid)
43 | s.Nil(user)
44 | s.NoError(s.mock.ExpectationsWereMet())
45 |
46 | row := sqlmock.NewRows([]string{"id", "password"}).AddRow(uint64(1), pw)
47 | s.mock.ExpectQuery("(?i)select \\* from `gospider_user` where \\(user_name = \\?\\)").
48 | WithArgs("admin").
49 | WillReturnRows(row)
50 | valid2, user2, err2 := IsValidUser(s.gdb, "admin", "admin")
51 | s.NoError(err2)
52 | s.True(valid2)
53 | s.NotNil(user2)
54 | s.NoError(s.mock.ExpectationsWereMet())
55 |
56 | }
57 |
--------------------------------------------------------------------------------
/web/static/src/components/Hamburger/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
13 |
14 |
15 |
30 |
31 |
46 |
--------------------------------------------------------------------------------
/web/static/build/build.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | require('./check-versions')()
3 |
4 | const ora = require('ora')
5 | const rm = require('rimraf')
6 | const path = require('path')
7 | const chalk = require('chalk')
8 | const webpack = require('webpack')
9 | const config = require('../config')
10 | const webpackConfig = require('./webpack.prod.conf')
11 | var connect = require('connect')
12 | var serveStatic = require('serve-static')
13 |
14 | const spinner = ora(
15 | 'building for ' + process.env.env_config + ' environment...'
16 | )
17 | spinner.start()
18 |
19 | rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => {
20 | if (err) throw err
21 | webpack(webpackConfig, (err, stats) => {
22 | spinner.stop()
23 | if (err) throw err
24 | process.stdout.write(
25 | stats.toString({
26 | colors: true,
27 | modules: false,
28 | children: false,
29 | chunks: false,
30 | chunkModules: false
31 | }) + '\n\n'
32 | )
33 |
34 | if (stats.hasErrors()) {
35 | console.log(chalk.red(' Build failed with errors.\n'))
36 | process.exit(1)
37 | }
38 |
39 | console.log(chalk.cyan(' Build complete.\n'))
40 | console.log(
41 | chalk.yellow(
42 | ' Tip: built files are meant to be served over an HTTP server.\n' +
43 | " Opening index.html over file:// won't work.\n"
44 | )
45 | )
46 |
47 | if (process.env.npm_config_preview) {
48 | const port = 9526
49 | const host = 'http://localhost:' + port
50 | const basePath = config.build.assetsPublicPath
51 | const app = connect()
52 |
53 | app.use(
54 | basePath,
55 | serveStatic('./dist', {
56 | index: ['index.html', '/']
57 | })
58 | )
59 |
60 | app.listen(port, function() {
61 | console.log(
62 | chalk.green(`> Listening at http://localhost:${port}${basePath}`)
63 | )
64 | })
65 | }
66 | })
67 | })
68 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/dashboard.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/views/svg-icons/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Add and use
5 |
6 |
7 |
8 |
9 |
10 |
11 | {{generateIconCode(item)}}
12 |
13 |
14 |
15 | {{item}}
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
50 |
51 |
78 |
--------------------------------------------------------------------------------
/web/service/checktask.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "github.com/jinzhu/gorm"
5 | "github.com/nange/gospider/common"
6 | "github.com/nange/gospider/web/core"
7 | "github.com/nange/gospider/web/model"
8 | log "github.com/sirupsen/logrus"
9 | )
10 |
11 | // check task status
12 | // 1. set status to TaskStatusRunning if status is TaskStatusUnexceptedExited
13 | // 2. restart task if status is TaskStatusCompleted, TaskStatusRunning
14 | func CheckTask() {
15 | log.Infof("starting check task goroutine")
16 | qs := model.NewTaskQuerySet(core.GetGormDB())
17 |
18 | tasks := make([]model.Task, 0)
19 | if err := qs.All(&tasks); err != nil {
20 | if err == gorm.ErrRecordNotFound {
21 | log.Infof("no task found, exit service.CheckTask method")
22 | return
23 | }
24 | log.Errorf("query task list err: %+v", err)
25 | return
26 | }
27 |
28 | for _, task := range tasks {
29 | if task.Status == common.TaskStatusRunning {
30 | log.Infof("set task status to TaskStatusUnexceptedExited, taskID:%v", task.ID)
31 | err := model.NewTaskQuerySet(core.GetGormDB()).IDEq(task.ID).
32 | GetUpdater().SetStatus(common.TaskStatusUnexceptedExited).Update()
33 | if err != nil {
34 | log.Errorf("update task status err: %+v", err)
35 | continue
36 | }
37 | }
38 |
39 | if (task.Status == common.TaskStatusCompleted || task.Status == common.TaskStatusRunning ||
40 | task.Status == common.TaskStatusUnexceptedExited) && task.CronSpec != "" {
41 | if err := CreateCronTask(task); err != nil {
42 | log.Errorf("restart task err:%+v", err)
43 | continue
44 | }
45 | }
46 | }
47 |
48 | }
49 |
50 | func CreateCronTask(task model.Task) error {
51 | log.Infof("restarting task, taskID:%v", task.ID)
52 |
53 | ct, err := NewCronTask(task.ID, task.CronSpec, GetMTSChan())
54 | if err != nil {
55 | log.Errorf("new cron task failed! err:%+v", err)
56 | return err
57 | }
58 |
59 | if err := ct.Start(); err != nil {
60 | log.Errorf("start cron task failed! err:%+v", err)
61 | return err
62 | }
63 |
64 | return nil
65 | }
66 |
--------------------------------------------------------------------------------
/web/router/task/create_task.go:
--------------------------------------------------------------------------------
1 | package task
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/nange/gospider/common"
9 | "github.com/nange/gospider/spider"
10 | "github.com/nange/gospider/web/core"
11 | "github.com/nange/gospider/web/model"
12 | "github.com/nange/gospider/web/service"
13 | log "github.com/sirupsen/logrus"
14 | )
15 |
16 | type CreateTaskReq struct {
17 | model.Task
18 | }
19 |
20 | type CreateTaskResp struct {
21 | ID uint64 `json:"id"`
22 | CreateAt time.Time `json:"create_at"`
23 | }
24 |
25 | func CreateTask(c *gin.Context) {
26 | var req CreateTaskReq
27 | if err := c.BindJSON(&req); err != nil {
28 | log.Errorf("bind json failed! err:%+v", err)
29 | c.AbortWithStatus(http.StatusBadRequest)
30 | return
31 | }
32 | log.Infof("req:%+v", req)
33 |
34 | task := req.Task
35 | task.Status = common.TaskStatusRunning
36 | if err := task.Create(core.GetGormDB()); err != nil {
37 | log.Errorf("create task failed! err:%+v", err)
38 | c.AbortWithStatus(http.StatusInternalServerError)
39 | return
40 | }
41 |
42 | spiderTask, err := service.GetSpiderTaskByModel(&task)
43 | if err != nil {
44 | log.Errorf("spider get model task failed! err:%+v", err)
45 | c.AbortWithStatus(http.StatusInternalServerError)
46 | return
47 | }
48 | s := spider.New(spiderTask, service.GetMTSChan())
49 | if err := s.Run(); err != nil {
50 | log.Errorf("spider run task failed! err:%+v", err)
51 | c.AbortWithStatus(http.StatusInternalServerError)
52 | return
53 | }
54 |
55 | if task.CronSpec != "" {
56 | log.Infof("starting cron task:%s", task.CronSpec)
57 | ct, err := service.NewCronTask(task.ID, task.CronSpec, service.GetMTSChan())
58 | if err != nil {
59 | log.Errorf("new cron task failed! err:%+v", err)
60 | } else {
61 | if err := ct.Start(); err != nil {
62 | log.Errorf("start cron task failed! err:%+v", err)
63 | }
64 | }
65 |
66 | }
67 |
68 | c.JSON(http.StatusOK, &CreateTaskResp{
69 | ID: task.ID,
70 | CreateAt: task.CreatedAt,
71 | })
72 | }
73 |
--------------------------------------------------------------------------------
/web/static/src/components/Screenfull/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
14 |
15 |
16 |
17 |
55 |
56 |
66 |
--------------------------------------------------------------------------------
/web/static/src/lang/en.js:
--------------------------------------------------------------------------------
1 | export default {
2 | route: {
3 | dashboard: 'Dashboard',
4 | icons: 'Icons',
5 | errorPages: 'Error Pages',
6 | page401: '401',
7 | page404: '404',
8 | errorLog: 'Error Log',
9 | theme: 'Theme',
10 | i18n: 'I18n',
11 | taskManage: 'Task Manage',
12 | createTask: 'Create Task',
13 | editTask: 'Edit Task',
14 | taskList: 'Task List',
15 | dbManage: 'Database Manage',
16 | addExpDb: 'Add Export DB',
17 | expDbList: 'Export DB List'
18 | },
19 | task: {
20 | name: 'Task Alias',
21 | rule: 'Task Rule Name',
22 | desc: 'Task Describe',
23 | cron: 'Crontab Config',
24 | proxy: 'Proxy Urls',
25 | agent: 'User Agent',
26 | maxDepth: 'Max Depth',
27 | allowDomains: 'Allow Domains',
28 | urlFilter: 'URL Filters',
29 | maxBody: 'Max Body Size',
30 | requestTimeout: 'Request Timeout',
31 | outType: 'Output Type',
32 | autoMigrate: 'Auto Migrate',
33 | limitEn: 'Limit Enable',
34 | limitDomainGlob: 'Limit Domain Glob',
35 | limitDelay: 'Limit Delay',
36 | limitRandomDelay: 'Limit Random Delay',
37 | limitPara: 'Limit Parallelism',
38 | add: 'Create Task',
39 | update: 'Update Task',
40 | id: 'ID',
41 | status: 'Status',
42 | counts: 'Run Counts',
43 | iscron: 'Is Cron',
44 | create_at: 'Create Time',
45 | actions: 'Operation',
46 | info: 'Info',
47 | edit: 'Edit',
48 | stop: 'Stop',
49 | start: 'Start',
50 | restart: 'Restart'
51 | },
52 | exportdb: {
53 | id: 'ID',
54 | showname: 'Show Name',
55 | host: 'Host',
56 | port: 'Post',
57 | user: 'User',
58 | password: 'Password',
59 | dbname: 'Db Name',
60 | actions: 'Operation',
61 | edit: 'Edit',
62 | delete: 'Delete'
63 | },
64 | navbar: {
65 | logOut: 'Log Out',
66 | dashboard: 'Dashboard',
67 | screenfull: 'screenfull',
68 | theme: 'theme'
69 | },
70 | login: {
71 | title: 'Login Form',
72 | logIn: 'Log in',
73 | username: 'Username',
74 | password: 'Password'
75 | },
76 | tagsView: {
77 | close: 'Close',
78 | closeOthers: 'Close Others',
79 | closeAll: 'Close All'
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/wechat.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spider/errors.go:
--------------------------------------------------------------------------------
1 | package spider
2 |
3 | import "github.com/pkg/errors"
4 |
5 | var (
6 | // ErrTaskRuleNotExist is the error type for task rule not exist
7 | ErrTaskRuleNotExist = errors.New("task rule not exist")
8 | // ErrTaskRuleIsNil is the error thrown when a nil rule registered
9 | ErrTaskRuleIsNil = errors.New("task rule is nil")
10 | // ErrTaskRuleNameIsEmpty is the error thrown when the ruleName is empty
11 | ErrTaskRuleNameIsEmpty = errors.New("task rule name is empty")
12 | // ErrTaskRuleNameDuplicated is the error thrown if the rule name is duplicated
13 | ErrTaskRuleNameDuplicated = errors.New("task rule name is Duplicated")
14 | // ErrTaskRuleHeadIsNil is the error thrown if the rule's head is nil
15 | ErrTaskRuleHeadIsNil = errors.New("task rule head is nil")
16 | // ErrTaskRuleNodesLenInvalid is the error thrown if the rule's nodes len is invalid
17 | ErrTaskRuleNodesLenInvalid = errors.New("task rule nodes len is invalid")
18 | // ErrTaskRuleNodesKeyInvalid is the error thrown if the rule's key len is invalid
19 | ErrTaskRuleNodesKeyInvalid = errors.New("task rule nodes key should start from 0 and monotonically increasing")
20 | // ErrTaskRunningTimeout is the error type for task running timeout
21 | ErrTaskRunningTimeout = errors.New("task running timeout")
22 | )
23 |
24 | var (
25 | // ErrOutputFieldsNotMatchOutputRow is the error type for output fields not match out put row
26 | ErrOutputFieldsNotMatchOutputRow = errors.New("output fields not match out put row")
27 | // ErrTooManyOutputNamespace is the error type for for too many output namespace
28 | ErrTooManyOutputNamespace = errors.New("too many output namespace")
29 | // ErrOutputToMultipleTableDisabled is the error thrown if "OutputToMultipleTable" is false
30 | ErrOutputToMultipleTableDisabled = errors.New("output to multiple tables disabled")
31 | // ErrOutputTypeNotSupported is the error type for unknown output type
32 | ErrOutputTypeNotSupported = errors.New("output type not supported")
33 | // ErrMultConfNamespaceNotFound is the error type for mult conf namespace not found
34 | ErrMultConfNamespaceNotFound = errors.New("mult conf namespace not found")
35 | // ErrOutputParamNotSupported is the error type for unknown output param
36 | ErrOutputParamNotSupported = errors.New("output param not supported ")
37 | )
38 |
--------------------------------------------------------------------------------
/web/static/src/components/ScrollPane/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
61 |
62 |
73 |
--------------------------------------------------------------------------------
/web/static/src/views/errorPage/401.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
返回
4 |
5 |
6 | 你没有权限去该页面
7 | 如有不满请联系你领导
8 |
9 | - 或者你可以去:
10 | -
11 | 回首页
12 |
13 | - 点我看图
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
49 |
50 |
88 |
--------------------------------------------------------------------------------
/web/static/src/utils/request.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import { Message } from 'element-ui'
3 | import store from '@/store'
4 | import { getToken } from '@/utils/auth'
5 |
6 | // create an axios instance
7 | const service = axios.create({
8 | baseURL: process.env.BASE_API, // api的base_url
9 | timeout: 5000 // request timeout
10 | })
11 |
12 | // request interceptor
13 | service.interceptors.request.use(
14 | config => {
15 | // Do something before request is sent
16 | if (store.getters.token) {
17 | // 让每个请求携带token-- ['X-Token']为自定义key 请根据实际情况自行修改
18 | config.headers['X-Token'] = getToken()
19 | }
20 | return config
21 | },
22 | error => {
23 | // Do something with request error
24 | console.log(error) // for debug
25 | Promise.reject(error)
26 | }
27 | )
28 |
29 | // respone interceptor
30 | service.interceptors.response.use(
31 | response => response,
32 | /**
33 | * 下面的注释为通过在response里,自定义code来标示请求状态
34 | * 当code返回如下情况则说明权限有问题,登出并返回到登录页
35 | * 如想通过xmlhttprequest来状态码标识 逻辑可写在下面error中
36 | * 以下代码均为样例,请结合自生需求加以修改,若不需要,则可删除
37 | */
38 | // response => {
39 | // const res = response.data
40 | // if (res.code !== 20000) {
41 | // Message({
42 | // message: res.message,
43 | // type: 'error',
44 | // duration: 5 * 1000
45 | // })
46 | // // 50008:非法的token; 50012:其他客户端登录了; 50014:Token 过期了;
47 | // if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
48 | // // 请自行在引入 MessageBox
49 | // // import { Message, MessageBox } from 'element-ui'
50 | // MessageBox.confirm('你已被登出,可以取消继续留在该页面,或者重新登录', '确定登出', {
51 | // confirmButtonText: '重新登录',
52 | // cancelButtonText: '取消',
53 | // type: 'warning'
54 | // }).then(() => {
55 | // store.dispatch('FedLogOut').then(() => {
56 | // location.reload() // 为了重新实例化vue-router对象 避免bug
57 | // })
58 | // })
59 | // }
60 | // return Promise.reject('error')
61 | // } else {
62 | // return response.data
63 | // }
64 | // },
65 | error => {
66 | console.log('err' + error) // for debug
67 | Message({
68 | message: error.message,
69 | type: 'error',
70 | duration: 5 * 1000
71 | })
72 | return Promise.reject(error)
73 | }
74 | )
75 |
76 | export default service
77 |
--------------------------------------------------------------------------------
/web/static/src/store/modules/tagsView.js:
--------------------------------------------------------------------------------
1 | const tagsView = {
2 | state: {
3 | visitedViews: [],
4 | cachedViews: []
5 | },
6 | mutations: {
7 | ADD_VISITED_VIEWS: (state, view) => {
8 | if (state.visitedViews.some(v => v.path === view.path)) return
9 | state.visitedViews.push(
10 | Object.assign({}, view, {
11 | title: view.meta.title || 'no-name'
12 | })
13 | )
14 | if (!view.meta.noCache) {
15 | state.cachedViews.push(view.name)
16 | }
17 | },
18 | DEL_VISITED_VIEWS: (state, view) => {
19 | for (const [i, v] of state.visitedViews.entries()) {
20 | if (v.path === view.path) {
21 | state.visitedViews.splice(i, 1)
22 | break
23 | }
24 | }
25 | for (const i of state.cachedViews) {
26 | if (i === view.name) {
27 | const index = state.cachedViews.indexOf(i)
28 | state.cachedViews.splice(index, 1)
29 | break
30 | }
31 | }
32 | },
33 | DEL_OTHERS_VIEWS: (state, view) => {
34 | for (const [i, v] of state.visitedViews.entries()) {
35 | if (v.path === view.path) {
36 | state.visitedViews = state.visitedViews.slice(i, i + 1)
37 | break
38 | }
39 | }
40 | for (const i of state.cachedViews) {
41 | if (i === view.name) {
42 | const index = state.cachedViews.indexOf(i)
43 | state.cachedViews = state.cachedViews.slice(index, index + 1)
44 | break
45 | }
46 | }
47 | },
48 | DEL_ALL_VIEWS: state => {
49 | state.visitedViews = []
50 | state.cachedViews = []
51 | }
52 | },
53 | actions: {
54 | addVisitedViews({ commit }, view) {
55 | commit('ADD_VISITED_VIEWS', view)
56 | },
57 | delVisitedViews({ commit, state }, view) {
58 | return new Promise(resolve => {
59 | commit('DEL_VISITED_VIEWS', view)
60 | resolve([...state.visitedViews])
61 | })
62 | },
63 | delOthersViews({ commit, state }, view) {
64 | return new Promise(resolve => {
65 | commit('DEL_OTHERS_VIEWS', view)
66 | resolve([...state.visitedViews])
67 | })
68 | },
69 | delAllViews({ commit, state }) {
70 | return new Promise(resolve => {
71 | commit('DEL_ALL_VIEWS')
72 | resolve([...state.visitedViews])
73 | })
74 | }
75 | }
76 | }
77 |
78 | export default tagsView
79 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/zip.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/web/static/src/icons/svg/form.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/static/src/permission.js:
--------------------------------------------------------------------------------
1 | import router from './router'
2 | import store from './store'
3 | import { Message } from 'element-ui'
4 | import NProgress from 'nprogress' // progress bar
5 | import 'nprogress/nprogress.css'// progress bar style
6 | import { getToken } from '@/utils/auth' // getToken from cookie
7 |
8 | NProgress.configure({ showSpinner: false })// NProgress Configuration
9 |
10 | // permission judge function
11 | function hasPermission(roles, permissionRoles) {
12 | if (roles.indexOf('admin') >= 0) return true // admin permission passed directly
13 | if (!permissionRoles) return true
14 | return roles.some(role => permissionRoles.indexOf(role) >= 0)
15 | }
16 |
17 | const whiteList = ['/login', '/authredirect']// no redirect whitelist
18 |
19 | router.beforeEach((to, from, next) => {
20 | NProgress.start() // start progress bar
21 | if (getToken()) { // determine if there has token
22 | /* has token*/
23 | if (to.path === '/login') {
24 | next({ path: '/' })
25 | NProgress.done() // if current page is dashboard will not trigger afterEach hook, so manually handle it
26 | } else {
27 | if (store.getters.roles.length === 0) { // 判断当前用户是否已拉取完user_info信息
28 | store.dispatch('GetUserInfo').then(res => { // 拉取user_info
29 | const roles = res.data.roles // note: roles must be a array! such as: ['editor','develop']
30 | store.dispatch('GenerateRoutes', { roles }).then(() => { // 根据roles权限生成可访问的路由表
31 | router.addRoutes(store.getters.addRouters) // 动态添加可访问路由表
32 | next({ ...to, replace: true }) // hack方法 确保addRoutes已完成 ,set the replace: true so the navigation will not leave a history record
33 | })
34 | }).catch((err) => {
35 | store.dispatch('FedLogOut').then(() => {
36 | Message.error(err || 'Verification failed, please login again')
37 | next({ path: '/' })
38 | })
39 | })
40 | } else {
41 | // 没有动态改变权限的需求可直接next() 删除下方权限判断 ↓
42 | if (hasPermission(store.getters.roles, to.meta.roles)) {
43 | next()
44 | } else {
45 | next({ path: '/401', replace: true, query: { noGoBack: true }})
46 | }
47 | // 可删 ↑
48 | }
49 | }
50 | } else {
51 | /* has no token*/
52 | if (whiteList.indexOf(to.path) !== -1) { // 在免登录白名单,直接进入
53 | next()
54 | } else {
55 | next('/login') // 否则全部重定向到登录页
56 | NProgress.done() // if current page is login will not trigger afterEach hook, so manually handle it
57 | }
58 | }
59 | })
60 |
61 | router.afterEach(() => {
62 | NProgress.done() // finish progress bar
63 | })
64 |
--------------------------------------------------------------------------------
/web/router/task/restart_task.go:
--------------------------------------------------------------------------------
1 | package task
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/nange/gospider/common"
9 | "github.com/nange/gospider/spider"
10 | "github.com/nange/gospider/web/core"
11 | "github.com/nange/gospider/web/model"
12 | "github.com/nange/gospider/web/service"
13 | "github.com/pkg/errors"
14 | log "github.com/sirupsen/logrus"
15 | )
16 |
17 | // 根据任务id重启定时任务
18 | func RestartTask(c *gin.Context) {
19 | taskIDStr := c.Param("id")
20 | if taskIDStr == "" {
21 | log.Warnf("RestartTaskReq taskID is empty")
22 | c.AbortWithStatus(http.StatusBadRequest)
23 | return
24 | }
25 | taskID, err := strconv.ParseUint(taskIDStr, 10, 64)
26 | if err != nil {
27 | log.Warnf("RestartTaskReq taskID format is invalid, taskID:%v", taskIDStr)
28 | c.AbortWithStatus(http.StatusBadRequest)
29 | return
30 | }
31 | log.Infof("RestartTaskReq:%+v", taskID)
32 |
33 | if taskLock.IsRunning(taskID) {
34 | c.String(http.StatusConflict, "other operation is running")
35 | return
36 | }
37 | defer taskLock.Complete(taskID)
38 |
39 | // query task info from db
40 | task := &model.Task{}
41 | err = model.NewTaskQuerySet(core.GetGormDB()).IDEq(taskID).One(task)
42 | if err != nil {
43 | log.Errorf("RestartTaskReq query model task fail, taskID: %v , err: %+v", taskIDStr, err)
44 | c.AbortWithStatus(http.StatusInternalServerError)
45 | return
46 | }
47 | // only allow crontab task
48 | if task.CronSpec == "" {
49 | log.Warnf("RestartTaskReq taskID is not crontab task, taskID: %v", taskIDStr)
50 | c.AbortWithStatus(http.StatusBadRequest)
51 | return
52 | }
53 |
54 | // check task status
55 | if !taskCanBeRestart(task) {
56 | log.Warnf("StartTaskReq taskID status is non-conformance , taskID: %v", taskIDStr)
57 | c.AbortWithStatus(http.StatusBadRequest)
58 | return
59 | }
60 |
61 | // create crontab task
62 | err = service.CreateCronTask(*task)
63 | if err != nil {
64 | log.Errorf("RestartTaskReq run task fail, taskID: %v , err: %+v", taskIDStr, err)
65 | c.AbortWithStatus(http.StatusInternalServerError)
66 | return
67 | }
68 | // update task status
69 | err = model.NewTaskQuerySet(core.GetGormDB()).IDEq(taskID).GetUpdater().SetStatus(common.TaskStatusCompleted).Update()
70 | if err != nil {
71 | // stop cron task
72 | if ct := service.GetCronTask(taskID); ct != nil {
73 | ct.Stop()
74 | }
75 |
76 | // cancel spider task
77 | spider.CancelTask(taskID)
78 |
79 | log.Errorf("RestartTaskReq update task status err:%+v", errors.WithStack(err))
80 | c.String(http.StatusInternalServerError, "")
81 | return
82 | }
83 | c.String(http.StatusOK, "success")
84 | }
85 |
86 | func taskCanBeRestart(task *model.Task) bool {
87 | return task.Status == common.TaskStatusStopped
88 | }
89 |
--------------------------------------------------------------------------------
/_example/rule/baidunews/baidu_news.go:
--------------------------------------------------------------------------------
1 | package baidunews
2 |
3 | import (
4 | "github.com/nange/gospider/spider"
5 | log "github.com/sirupsen/logrus"
6 | )
7 |
8 | func init() {
9 | spider.Register(rule)
10 | }
11 |
12 | var (
13 | outputFields = []string{"category", "title", "link"}
14 | outputFields2 = []string{"category", "category_link"}
15 |
16 | namespace1 = "baidu_news"
17 | namespace2 = "baidu_category"
18 | )
19 | var multNamespaceConf = map[string]*spider.MultipleNamespaceConf{
20 | namespace1: {
21 | OutputFields: outputFields,
22 | OutputConstraints: spider.NewStringsConstraints(outputFields, 64, 128, 512),
23 | },
24 | namespace2: {
25 | OutputFields: outputFields2,
26 | OutputConstraints: spider.NewStringsConstraints(outputFields2, 64, 256),
27 | },
28 | }
29 |
30 | // 演示如何在一条规则里面,同时需要导出数据到两张表
31 | var rule = &spider.TaskRule{
32 | Name: "百度新闻规则",
33 | Description: "抓取百度新闻各个分类的最新焦点新闻以及最新的新闻分类和链接",
34 | OutputToMultipleNamespace: true,
35 | MultipleNamespaceConf: multNamespaceConf,
36 | Rule: &spider.Rule{
37 | Head: func(ctx *spider.Context) error {
38 | return ctx.VisitForNext("http://news.baidu.com")
39 | },
40 | Nodes: map[int]*spider.Node{
41 | 0: step1, // 第一步: 获取所有分类
42 | 1: step2, // 第二步: 获取每个分类的新闻标题链接
43 | },
44 | },
45 | }
46 |
47 | var step1 = &spider.Node{
48 | OnRequest: func(ctx *spider.Context, req *spider.Request) {
49 | log.Println("Visting", req.URL.String())
50 | },
51 | OnHTML: map[string]func(*spider.Context, *spider.HTMLElement) error{
52 | `#channel-all .menu-list a`: func(ctx *spider.Context, el *spider.HTMLElement) error { // 获取所有分类
53 | category := el.Text
54 | ctx.PutReqContextValue("category", category)
55 | link := el.Attr("href")
56 |
57 | if category != "首页" {
58 | err := ctx.Output(map[int]interface{}{
59 | 0: category,
60 | 1: ctx.AbsoluteURL(link),
61 | }, namespace2)
62 | if err != nil {
63 | return err
64 | }
65 | }
66 |
67 | return ctx.VisitForNextWithContext(link)
68 | },
69 | },
70 | }
71 |
72 | var step2 = &spider.Node{
73 | OnRequest: func(ctx *spider.Context, req *spider.Request) {
74 | log.Println("Visting", req.URL.String())
75 | },
76 | OnHTML: map[string]func(*spider.Context, *spider.HTMLElement) error{
77 | `#col_focus a, .focal-news a, .auto-col-focus a, .l-common .fn-c a`: func(ctx *spider.Context, el *spider.HTMLElement) error {
78 | title := el.Text
79 | link := el.Attr("href")
80 | if title == "" || link == "javascript:void(0);" {
81 | return nil
82 | }
83 |
84 | category := ctx.GetReqContextValue("category")
85 | return ctx.Output(map[int]interface{}{
86 | 0: category,
87 | 1: title,
88 | 2: link,
89 | }, namespace1)
90 | },
91 | },
92 | }
93 |
--------------------------------------------------------------------------------
/spider/task_rule.go:
--------------------------------------------------------------------------------
1 | package spider
2 |
3 | import "github.com/pkg/errors"
4 |
5 | var rules = make(map[string]*TaskRule)
6 |
7 | // Register register a task rule
8 | func Register(rule *TaskRule) {
9 | if err := checkRule(rule); err != nil {
10 | panic(err)
11 | }
12 | rules[rule.Name] = rule
13 | }
14 |
15 | // MultipleNamespaceConf is the mutiple namespace conf
16 | type MultipleNamespaceConf struct {
17 | OutputFields []string
18 | OutputConstraints map[string]*OutputConstraint
19 | OutputTableOpts string
20 | }
21 |
22 | // TaskRule is the task rule define
23 | type TaskRule struct {
24 | Name string
25 | Description string
26 | OutputToMultipleNamespace bool
27 | MultipleNamespaceConf map[string]*MultipleNamespaceConf
28 | Namespace string
29 | OutputFields []string
30 | OutputConstraints map[string]*OutputConstraint
31 | OutputTableOpts string
32 | DisableCookies bool
33 | AllowURLRevisit bool
34 | IgnoreRobotsTxt bool
35 | InsecureSkipVerify bool
36 | ParseHTTPErrorResponse bool
37 | Rule *Rule
38 | }
39 |
40 | // GetTaskRule get task rule by ruleName
41 | func GetTaskRule(ruleName string) (*TaskRule, error) {
42 | if rule, ok := rules[ruleName]; ok {
43 | return rule, nil
44 | }
45 | return nil, errors.WithStack(ErrTaskRuleNotExist)
46 | }
47 |
48 | // GetTaskRuleKeys return all keys of task rule
49 | func GetTaskRuleKeys() []string {
50 | keys := make([]string, 0, len(rules))
51 | for k := range rules {
52 | keys = append(keys, k)
53 | }
54 |
55 | return keys
56 | }
57 |
58 | // Rule the rule define
59 | type Rule struct {
60 | Head func(ctx *Context) error
61 | Nodes map[int]*Node
62 | }
63 |
64 | // Node the rule node of a task
65 | type Node struct {
66 | OnRequest func(ctx *Context, req *Request)
67 | OnError func(ctx *Context, res *Response, err error) error
68 | OnResponse func(ctx *Context, res *Response) error
69 | OnHTML map[string]func(ctx *Context, el *HTMLElement) error
70 | OnXML map[string]func(ctx *Context, el *XMLElement) error
71 | OnScraped func(ctx *Context, res *Response) error
72 | }
73 |
74 | func checkRule(rule *TaskRule) error {
75 | if rule == nil || rule.Rule == nil {
76 | return ErrTaskRuleIsNil
77 | }
78 | if rule.Name == "" {
79 | return ErrTaskRuleNameIsEmpty
80 | }
81 | if rule.Rule.Head == nil {
82 | return ErrTaskRuleHeadIsNil
83 | }
84 | if len(rule.Rule.Nodes) == 0 {
85 | return ErrTaskRuleNodesLenInvalid
86 | }
87 | for i := 0; i < len(rule.Rule.Nodes); i++ {
88 | if _, ok := rule.Rule.Nodes[i]; !ok {
89 | return ErrTaskRuleNodesKeyInvalid
90 | }
91 | }
92 | if _, ok := rules[rule.Name]; ok {
93 | return ErrTaskRuleNameDuplicated
94 | }
95 |
96 | return nil
97 | }
98 |
--------------------------------------------------------------------------------
/web/router/route.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "time"
5 |
6 | jwt "github.com/appleboy/gin-jwt/v2"
7 | "github.com/gin-gonic/gin"
8 | "github.com/gobuffalo/packr"
9 | "github.com/nange/gospider/web/core"
10 | "github.com/nange/gospider/web/model"
11 | "github.com/nange/gospider/web/router/exportdb"
12 | "github.com/nange/gospider/web/router/rule"
13 | "github.com/nange/gospider/web/router/task"
14 | "github.com/nange/gospider/web/router/user"
15 | log "github.com/sirupsen/logrus"
16 | )
17 |
18 | func Route(engine *gin.Engine) {
19 | authMiddleware, err := JwtAuth()
20 | if err != nil {
21 | log.Fatalf("get jwt middleware err [%+v]", err)
22 | }
23 |
24 | engine.POST("/login", authMiddleware.LoginHandler)
25 | api := engine.Group("/api")
26 | api.Use(authMiddleware.MiddlewareFunc())
27 | {
28 | api.GET("/tasks", task.GetTaskList)
29 | api.GET("/tasks/:id", task.GetTaskByID)
30 | api.POST("/tasks", task.CreateTask)
31 | api.PUT("/tasks/:id", task.UpdateTask)
32 | api.PUT("/tasks/:id/stop", task.StopTask)
33 | api.PUT("/tasks/:id/start", task.StartTask)
34 | api.PUT("/tasks/:id/restart", task.RestartTask)
35 |
36 | api.GET("/rules", rule.GetRuleList)
37 |
38 | api.GET("/exportdb", exportdb.GetExportDBList)
39 | api.POST("/exportdb", exportdb.CreateExportDB)
40 | api.DELETE("/exportdb/:id", exportdb.DeleteExportDB)
41 |
42 | api.GET("/user/info", user.GetUserInfo)
43 |
44 | }
45 |
46 | box := packr.NewBox("../static/dist")
47 | box2 := packr.NewBox("../static/dist/static")
48 | engine.StaticFS("/admin", box)
49 | engine.StaticFS("/static", box2)
50 | }
51 |
52 | func JwtAuth() (*jwt.GinJWTMiddleware, error) {
53 | type login struct {
54 | Username string `form:"username" json:"username" binding:"required"`
55 | Password string `form:"password" json:"password" binding:"required"`
56 | }
57 | middle, err := jwt.New(&jwt.GinJWTMiddleware{
58 | Key: []byte("gospider"),
59 | Timeout: 24 * time.Hour,
60 | MaxRefresh: 24 * time.Hour,
61 | PayloadFunc: func(data interface{}) jwt.MapClaims {
62 | if v, ok := data.(*model.User); ok {
63 | return jwt.MapClaims{
64 | jwt.IdentityKey: v,
65 | }
66 | }
67 | return jwt.MapClaims{}
68 | },
69 | Authenticator: func(c *gin.Context) (i interface{}, e error) {
70 | var loginVals login
71 | if err := c.ShouldBind(&loginVals); err != nil {
72 | return "", jwt.ErrMissingLoginValues
73 | }
74 | valid, user, err := model.IsValidUser(core.GetGormDB(), loginVals.Username, loginVals.Password)
75 | if err != nil {
76 | return nil, err
77 | }
78 | if valid {
79 | return user, nil
80 | }
81 |
82 | return nil, jwt.ErrFailedAuthentication
83 | },
84 | TokenLookup: "header: Authorization, query: token, cookie: jwt",
85 | SendCookie: true,
86 | })
87 |
88 | return middle, err
89 | }
90 |
--------------------------------------------------------------------------------
/web/model/user.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/jinzhu/gorm"
7 | "github.com/nange/gospider/web/core"
8 | log "github.com/sirupsen/logrus"
9 | "golang.org/x/crypto/bcrypt"
10 | )
11 |
12 | //go:generate goqueryset -in user.go
13 | // gen:qs
14 | type User struct {
15 | ID uint64 `json:"id,string" gorm:"column:id;type:bigint unsigned AUTO_INCREMENT;primary_key"`
16 | UserName string `json:"user_name" gorm:"column:user_name;type:varchar(32) not null;unique_index:uk_uname"`
17 | Password string `json:"-" gorm:"column:password;type:varchar(128) not null"`
18 | Email string `json:"email" gorm:"column:email;type:varchar(32) not null default '';index:idx_email"`
19 | Roles string `json:"roles" gorm:"column:roles;type:varchar(128) not null default ''"`
20 | Introduction string `json:"introduction" gorm:"column:introduction;type:varchar(128) not null default ''"`
21 | Avatar string `json:"avatar" gorm:"column:avatar;type:varchar(256) not null default ''"`
22 | CreatedAt time.Time `json:"created_at" gorm:"column:created_at;type:datetime;not null;default:CURRENT_TIMESTAMP;index:idx_created_at"`
23 | UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at;type:datetime;not null;default:CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;index:idx_updated_at"`
24 | }
25 |
26 | func (o *User) TableName() string {
27 | return "gospider_user"
28 | }
29 |
30 | func init() {
31 | core.Register(&User{})
32 | }
33 |
34 | func IsValidUser(db *gorm.DB, username, password string) (bool, *User, error) {
35 | user := &User{}
36 | query := NewUserQuerySet(db)
37 | if err := query.UserNameEq(username).One(user); err != nil {
38 | if err == gorm.ErrRecordNotFound {
39 | return false, nil, nil
40 | }
41 | log.Errorf("fetch user by name err [%+v]", err)
42 | return false, nil, err
43 | }
44 |
45 | if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
46 | return false, nil, nil
47 | }
48 |
49 | return true, user, nil
50 | }
51 |
52 | func GenUserHashPassword(password string) (string, error) {
53 | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
54 | if err != nil {
55 | return "", err
56 | }
57 | return string(hash), nil
58 | }
59 |
60 | func InitAdminUserIfNeeded(db *gorm.DB) error {
61 | user := &User{}
62 | query := NewUserQuerySet(db)
63 | err := query.UserNameEq("admin").One(user)
64 | if err == nil {
65 | return nil
66 | }
67 | if err == gorm.ErrRecordNotFound {
68 | user.UserName = "admin"
69 | pw, err := GenUserHashPassword("admin")
70 | if err != nil {
71 | return err
72 | }
73 | user.Password = pw
74 | user.Avatar = "/admin/gopher.png"
75 | user.Introduction = "admin user"
76 | user.Roles = "admin"
77 |
78 | return user.Create(db)
79 | }
80 |
81 | return err
82 | }
83 |
--------------------------------------------------------------------------------
/common/db.go:
--------------------------------------------------------------------------------
1 | package common
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 | "time"
7 |
8 | "github.com/go-sql-driver/mysql"
9 | "github.com/jinzhu/gorm"
10 | "github.com/pkg/errors"
11 | )
12 |
13 | // MySQLConf is the mysql conf
14 | type MySQLConf struct {
15 | Host string
16 | Port int
17 | User string
18 | Password string
19 | DBName string
20 | MaxIdleConns int
21 | MaxOpenConns int
22 | MaxLifetime time.Duration
23 | }
24 |
25 | // NewGormDB return a new gorm db instance
26 | func NewGormDB(conf MySQLConf) (*gorm.DB, error) {
27 | dsn := getDSNWithDB(conf)
28 | db, err := gorm.Open("mysql", dsn)
29 | if err != nil {
30 | code, ok := GetSQLErrCode(err)
31 | if !ok {
32 | return nil, errors.WithStack(err)
33 | }
34 | if code == 1049 { // Database not exists
35 | if err := createDatabase(conf); err != nil {
36 | return nil, err
37 | }
38 | }
39 | db, err = gorm.Open("mysql", dsn)
40 | if err != nil {
41 | return nil, errors.WithStack(err)
42 | }
43 | }
44 |
45 | if conf.MaxIdleConns == 0 {
46 | db.DB().SetMaxIdleConns(3)
47 | }
48 | if conf.MaxOpenConns == 0 {
49 | db.DB().SetMaxOpenConns(5)
50 | }
51 | if conf.MaxLifetime == 0 {
52 | db.DB().SetConnMaxLifetime(time.Hour)
53 | }
54 |
55 | return db, nil
56 | }
57 |
58 | // NewDB returns a new sql.DB instance
59 | func NewDB(conf MySQLConf) (*sql.DB, error) {
60 | dsn := getDSNWithDB(conf)
61 | db, err := sql.Open("mysql", dsn)
62 | if err != nil {
63 | return nil, errors.WithStack(err)
64 | }
65 |
66 | db.SetConnMaxLifetime(time.Hour)
67 | db.SetMaxIdleConns(2)
68 | db.SetMaxOpenConns(10)
69 |
70 | return db, nil
71 | }
72 |
73 | func getbaseDSN(conf MySQLConf) string {
74 | dsn := fmt.Sprintf("%s:%s@(%s:%d)/?charset=utf8mb4&parseTime=True&loc=Local",
75 | conf.User, conf.Password, conf.Host, conf.Port)
76 |
77 | return dsn
78 | }
79 |
80 | func getDSNWithDB(conf MySQLConf) string {
81 | dsn := fmt.Sprintf("%s:%s@(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
82 | conf.User, conf.Password, conf.Host, conf.Port, conf.DBName)
83 |
84 | return dsn
85 | }
86 |
87 | // GetSQLErrCode returns error code if err is a mysql error
88 | func GetSQLErrCode(err error) (int, bool) {
89 | mysqlErr, ok := errors.Cause(err).(*mysql.MySQLError)
90 | if !ok {
91 | return -1, false
92 | }
93 |
94 | return int(mysqlErr.Number), true
95 | }
96 |
97 | func createDatabase(conf MySQLConf) error {
98 | dsn := getbaseDSN(conf)
99 | db, err := sql.Open("mysql", dsn)
100 | if err != nil {
101 | return errors.WithStack(err)
102 | }
103 | defer db.Close()
104 |
105 | _, err = db.Exec("CREATE DATABASE IF NOT EXISTS " + conf.DBName)
106 | if err != nil {
107 | return errors.WithStack(err)
108 | }
109 |
110 | return nil
111 | }
112 |
--------------------------------------------------------------------------------
/web/static/src/directive/sticky.js:
--------------------------------------------------------------------------------
1 | const vueSticky = {}
2 | let listenAction
3 | vueSticky.install = Vue => {
4 | Vue.directive('sticky', {
5 | inserted(el, binding) {
6 | const params = binding.value || {}
7 | const stickyTop = params.stickyTop || 0
8 | const zIndex = params.zIndex || 1000
9 | const elStyle = el.style
10 |
11 | elStyle.position = '-webkit-sticky'
12 | elStyle.position = 'sticky'
13 | // if the browser support css sticky(Currently Safari, Firefox and Chrome Canary)
14 | // if (~elStyle.position.indexOf('sticky')) {
15 | // elStyle.top = `${stickyTop}px`;
16 | // elStyle.zIndex = zIndex;
17 | // return
18 | // }
19 | const elHeight = el.getBoundingClientRect().height
20 | const elWidth = el.getBoundingClientRect().width
21 | elStyle.cssText = `top: ${stickyTop}px; z-index: ${zIndex}`
22 |
23 | const parentElm = el.parentNode || document.documentElement
24 | const placeholder = document.createElement('div')
25 | placeholder.style.display = 'none'
26 | placeholder.style.width = `${elWidth}px`
27 | placeholder.style.height = `${elHeight}px`
28 | parentElm.insertBefore(placeholder, el)
29 |
30 | let active = false
31 |
32 | const getScroll = (target, top) => {
33 | const prop = top ? 'pageYOffset' : 'pageXOffset'
34 | const method = top ? 'scrollTop' : 'scrollLeft'
35 | let ret = target[prop]
36 | if (typeof ret !== 'number') {
37 | ret = window.document.documentElement[method]
38 | }
39 | return ret
40 | }
41 |
42 | const sticky = () => {
43 | if (active) {
44 | return
45 | }
46 | if (!elStyle.height) {
47 | elStyle.height = `${el.offsetHeight}px`
48 | }
49 |
50 | elStyle.position = 'fixed'
51 | elStyle.width = `${elWidth}px`
52 | placeholder.style.display = 'inline-block'
53 | active = true
54 | }
55 |
56 | const reset = () => {
57 | if (!active) {
58 | return
59 | }
60 |
61 | elStyle.position = ''
62 | placeholder.style.display = 'none'
63 | active = false
64 | }
65 |
66 | const check = () => {
67 | const scrollTop = getScroll(window, true)
68 | const offsetTop = el.getBoundingClientRect().top
69 | if (offsetTop < stickyTop) {
70 | sticky()
71 | } else {
72 | if (scrollTop < elHeight + stickyTop) {
73 | reset()
74 | }
75 | }
76 | }
77 | listenAction = () => {
78 | check()
79 | }
80 |
81 | window.addEventListener('scroll', listenAction)
82 | },
83 |
84 | unbind() {
85 | window.removeEventListener('scroll', listenAction)
86 | }
87 | })
88 | }
89 |
90 | export default vueSticky
91 |
92 |
--------------------------------------------------------------------------------
/web/service/cron.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "fmt"
5 | "sync"
6 |
7 | "github.com/nange/gospider/common"
8 | "github.com/nange/gospider/spider"
9 | "github.com/nange/gospider/web/core"
10 | "github.com/nange/gospider/web/model"
11 | "github.com/pkg/errors"
12 | "github.com/robfig/cron"
13 | log "github.com/sirupsen/logrus"
14 | )
15 |
16 | var (
17 | ErrCronTaskDuplicated = errors.New("cron task is Duplicated")
18 | )
19 |
20 | var cronTaskMap = &sync.Map{}
21 |
22 | type CronTask struct {
23 | taskID uint64
24 | cronSpec string
25 | cr *cron.Cron
26 | retCh chan<- common.MTS
27 | }
28 |
29 | func NewCronTask(taskID uint64, cronSpec string, retCh chan<- common.MTS) (*CronTask, error) {
30 | ct := &CronTask{
31 | taskID: taskID,
32 | cronSpec: cronSpec,
33 | cr: cron.New(),
34 | retCh: retCh,
35 | }
36 |
37 | if err := AddCronTask(ct); err != nil {
38 | return nil, errors.Wrap(err, "add cron task failed")
39 | }
40 |
41 | return ct, nil
42 | }
43 |
44 | func (ct *CronTask) Run() {
45 | task := &model.Task{}
46 | err := model.NewTaskQuerySet(core.GetGormDB()).IDEq(ct.taskID).One(task)
47 | if err != nil {
48 | log.Errorf("run cron task failed, query task err:%+v", errors.WithStack(err))
49 | return
50 | }
51 | if task.Status != common.TaskStatusCompleted && task.Status != common.TaskStatusUnexceptedExited {
52 | log.Warnf("run cron task failed, status:%+v", errors.New(task.Status.String()))
53 | return
54 | }
55 |
56 | spiderTask, err := GetSpiderTaskByModel(task)
57 | if err != nil {
58 | log.Errorf("run cron task failed, err:%+v", errors.WithStack(err))
59 | return
60 | }
61 | s := spider.New(spiderTask, ct.retCh)
62 | if err := s.Run(); err != nil {
63 | log.Errorf("run cron task failed, err:%+v", err)
64 | ct.retCh <- common.MTS{ID: task.ID, Status: common.TaskStatusUnexceptedExited}
65 | return
66 | }
67 |
68 | ct.retCh <- common.MTS{ID: task.ID, Status: common.TaskStatusRunning}
69 | }
70 |
71 | func (ct *CronTask) Start() error {
72 | if err := ct.cr.AddJob(ct.cronSpec, ct); err != nil {
73 | return errors.Wrapf(err, "cron add job failed, taskID:%d", ct.taskID)
74 | }
75 | ct.cr.Start()
76 | return nil
77 | }
78 |
79 | func (ct *CronTask) Stop() error {
80 | if ct.cr == nil {
81 | return errors.New("CronTask do not started")
82 | }
83 | cronTaskMap.Delete(ct.taskID)
84 |
85 | ct.cr.Stop()
86 | return nil
87 | }
88 |
89 | func GetCronTask(taskID uint64) *CronTask {
90 | ct, ok := cronTaskMap.Load(taskID)
91 | if !ok {
92 | return nil
93 | }
94 | return ct.(*CronTask)
95 | }
96 |
97 | func AddCronTask(ct *CronTask) error {
98 | if _, loaded := cronTaskMap.LoadOrStore(ct.taskID, ct); loaded {
99 | return errors.Wrap(ErrCronTaskDuplicated, fmt.Sprintf("add cron task failed, taskID:%d", ct.taskID))
100 | }
101 | return nil
102 | }
103 |
--------------------------------------------------------------------------------
/web/static/src/views/layout/components/Sidebar/SidebarItem.vue:
--------------------------------------------------------------------------------
1 |
2 |
30 |
31 |
32 |
81 |
82 |
--------------------------------------------------------------------------------
/web/router/task/start_task.go:
--------------------------------------------------------------------------------
1 | package task
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/nange/gospider/common"
9 | "github.com/nange/gospider/spider"
10 | "github.com/nange/gospider/web/core"
11 | "github.com/nange/gospider/web/model"
12 | "github.com/nange/gospider/web/service"
13 | "github.com/pkg/errors"
14 | log "github.com/sirupsen/logrus"
15 | )
16 |
17 | // 根据任务id启动非定时任务
18 | func StartTask(c *gin.Context) {
19 | taskIDStr := c.Param("id")
20 | if taskIDStr == "" {
21 | log.Warnf("StartTaskReq taskID is empty")
22 | c.AbortWithStatus(http.StatusBadRequest)
23 | return
24 | }
25 | taskID, err := strconv.ParseUint(taskIDStr, 10, 64)
26 | if err != nil {
27 | log.Warnf("StartTaskReq taskID format is invalid, taskID: %v", taskIDStr)
28 | c.AbortWithStatus(http.StatusBadRequest)
29 | return
30 | }
31 | log.Infof("StartTaskReq:%+v", taskID)
32 |
33 | if taskLock.IsRunning(taskID) {
34 | c.String(http.StatusConflict, "other operation is running")
35 | return
36 | }
37 | defer taskLock.Complete(taskID)
38 |
39 | // query task info from db
40 | task := &model.Task{}
41 | err = model.NewTaskQuerySet(core.GetGormDB()).IDEq(taskID).One(task)
42 | if err != nil {
43 | log.Errorf("StartTaskReq query model task fail, taskID: %v , err: %+v", taskIDStr, err)
44 | c.AbortWithStatus(http.StatusInternalServerError)
45 | return
46 | }
47 | // not allow crontab task
48 | if task.CronSpec != "" {
49 | log.Warnf("StartTaskReq taskID is crontab task, taskID: %v", taskIDStr)
50 | c.AbortWithStatus(http.StatusBadRequest)
51 | return
52 | }
53 |
54 | // check task status
55 | if !taskCanBeStart(task) {
56 | log.Warnf("StartTaskReq taskID status is non-conformance , taskID: %v", taskIDStr)
57 | c.AbortWithStatus(http.StatusBadRequest)
58 | return
59 | }
60 |
61 | // create Task Model
62 | spiderTask, err := service.GetSpiderTaskByModel(task)
63 | if err != nil {
64 | log.Errorf("StartTaskReq get model task fail, taskID: %v , err: %+v", taskIDStr, err)
65 | c.AbortWithStatus(http.StatusInternalServerError)
66 | return
67 | }
68 | // run Task Model
69 | s := spider.New(spiderTask, service.GetMTSChan())
70 | if err := s.Run(); err != nil {
71 | log.Errorf("StartTaskReq run task fail, taskID: %v , err: %+v", taskIDStr, err)
72 | c.AbortWithStatus(http.StatusInternalServerError)
73 | return
74 | }
75 | // update task status
76 | err = model.NewTaskQuerySet(core.GetGormDB()).IDEq(taskID).GetUpdater().SetStatus(common.TaskStatusRunning).Update()
77 | if err != nil {
78 | spider.CancelTask(taskID)
79 | log.Errorf("StartTaskReq update task status err:%+v", errors.WithStack(err))
80 | c.String(http.StatusInternalServerError, "")
81 | return
82 | }
83 |
84 | c.String(http.StatusOK, "success")
85 | }
86 |
87 | func taskCanBeStart(task *model.Task) bool {
88 | return task.Status == common.TaskStatusStopped ||
89 | task.Status == common.TaskStatusUnexceptedExited ||
90 | task.Status == common.TaskStatusCompleted ||
91 | task.Status == common.TaskStatusRunningTimeout
92 | }
93 |
--------------------------------------------------------------------------------
/web/static/src/styles/sidebar.scss:
--------------------------------------------------------------------------------
1 | #app {
2 | // 主体区域
3 | .main-container {
4 | min-height: 100%;
5 | transition: margin-left .28s;
6 | margin-left: 200px;
7 | position: relative;
8 | }
9 | // 侧边栏
10 | .sidebar-container {
11 | transition: width 0.28s;
12 | width: 200px !important;
13 | height: 100%;
14 | position: fixed;
15 | font-size: 0px;
16 | top: 0;
17 | bottom: 0;
18 | left: 0;
19 | z-index: 1001;
20 | overflow: hidden;
21 | //reset element-ui css
22 | .horizontal-collapse-transition {
23 | transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
24 | }
25 | .scrollbar-wrapper {
26 | overflow-x: hidden!important;
27 | .el-scrollbar__view {
28 | height: 100%;
29 | }
30 | }
31 | .is-horizontal {
32 | display: none;
33 | }
34 | a {
35 | display: inline-block;
36 | width: 100%;
37 | overflow: hidden;
38 | }
39 | .svg-icon {
40 | margin-right: 16px;
41 | }
42 | .el-menu {
43 | border: none;
44 | height: 100%;
45 | width: 100% !important;
46 | }
47 | }
48 | .hideSidebar {
49 | .sidebar-container {
50 | width: 36px !important;
51 | }
52 | .main-container {
53 | margin-left: 36px;
54 | }
55 | .submenu-title-noDropdown {
56 | padding-left: 10px !important;
57 | position: relative;
58 | .el-tooltip {
59 | padding: 0 10px !important;
60 | }
61 | }
62 | .el-submenu {
63 | overflow: hidden;
64 | &>.el-submenu__title {
65 | padding-left: 10px !important;
66 | .el-submenu__icon-arrow {
67 | display: none;
68 | }
69 | }
70 | }
71 | .el-menu--collapse {
72 | .el-submenu {
73 | &>.el-submenu__title {
74 | &>span {
75 | height: 0;
76 | width: 0;
77 | overflow: hidden;
78 | visibility: hidden;
79 | display: inline-block;
80 | }
81 | }
82 | }
83 | }
84 | }
85 | .sidebar-container .nest-menu .el-submenu>.el-submenu__title,
86 | .sidebar-container .el-submenu .el-menu-item {
87 | min-width: 180px !important;
88 | background-color: $subMenuBg !important;
89 | &:hover {
90 | background-color: $menuHover !important;
91 | }
92 | }
93 | .el-menu--collapse .el-menu .el-submenu {
94 | min-width: 180px !important;
95 | }
96 |
97 | //适配移动端
98 | .mobile {
99 | .main-container {
100 | margin-left: 0px;
101 | }
102 | .sidebar-container {
103 | transition: transform .28s;
104 | width: 180px !important;
105 | }
106 | &.hideSidebar {
107 | .sidebar-container {
108 | transition-duration: 0.3s;
109 | transform: translate3d(-180px, 0, 0);
110 | }
111 | }
112 | }
113 | .withoutAnimation {
114 | .main-container,
115 | .sidebar-container {
116 | transition: none;
117 | }
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/web/static/build/utils.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const path = require('path')
3 | const config = require('../config')
4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin')
5 | const packageConfig = require('../package.json')
6 |
7 | exports.assetsPath = function(_path) {
8 | const assetsSubDirectory =
9 | process.env.NODE_ENV === 'production'
10 | ? config.build.assetsSubDirectory
11 | : config.dev.assetsSubDirectory
12 |
13 | return path.posix.join(assetsSubDirectory, _path)
14 | }
15 |
16 | exports.cssLoaders = function(options) {
17 | options = options || {}
18 |
19 | const cssLoader = {
20 | loader: 'css-loader',
21 | options: {
22 | sourceMap: options.sourceMap
23 | }
24 | }
25 |
26 | const postcssLoader = {
27 | loader: 'postcss-loader',
28 | options: {
29 | sourceMap: options.sourceMap
30 | }
31 | }
32 |
33 | // generate loader string to be used with extract text plugin
34 | function generateLoaders(loader, loaderOptions) {
35 | const loaders = []
36 |
37 | // Extract CSS when that option is specified
38 | // (which is the case during production build)
39 | if (options.extract) {
40 | loaders.push(MiniCssExtractPlugin.loader)
41 | } else {
42 | loaders.push('vue-style-loader')
43 | }
44 |
45 | loaders.push(cssLoader)
46 |
47 | if (options.usePostCSS) {
48 | loaders.push(postcssLoader)
49 | }
50 |
51 | if (loader) {
52 | loaders.push({
53 | loader: loader + '-loader',
54 | options: Object.assign({}, loaderOptions, {
55 | sourceMap: options.sourceMap
56 | })
57 | })
58 | }
59 |
60 | return loaders
61 | }
62 | // https://vue-loader.vuejs.org/en/configurations/extract-css.html
63 | return {
64 | css: generateLoaders(),
65 | postcss: generateLoaders(),
66 | less: generateLoaders('less'),
67 | sass: generateLoaders('sass', {
68 | indentedSyntax: true
69 | }),
70 | scss: generateLoaders('sass'),
71 | stylus: generateLoaders('stylus'),
72 | styl: generateLoaders('stylus')
73 | }
74 | }
75 |
76 | // Generate loaders for standalone style files (outside of .vue)
77 | exports.styleLoaders = function(options) {
78 | const output = []
79 | const loaders = exports.cssLoaders(options)
80 |
81 | for (const extension in loaders) {
82 | const loader = loaders[extension]
83 | output.push({
84 | test: new RegExp('\\.' + extension + '$'),
85 | use: loader
86 | })
87 | }
88 |
89 | return output
90 | }
91 |
92 | exports.createNotifierCallback = () => {
93 | const notifier = require('node-notifier')
94 |
95 | return (severity, errors) => {
96 | if (severity !== 'error') return
97 |
98 | const error = errors[0]
99 | const filename = error.file && error.file.split('!').pop()
100 |
101 | notifier.notify({
102 | title: packageConfig.name,
103 | message: severity + ': ' + error.name,
104 | subtitle: filename || '',
105 | icon: path.join(__dirname, 'logo.png')
106 | })
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/web/static/build/webpack.dev.conf.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const path = require('path')
3 | const utils = require('./utils')
4 | const webpack = require('webpack')
5 | const config = require('../config')
6 | const merge = require('webpack-merge')
7 | const baseWebpackConfig = require('./webpack.base.conf')
8 | const HtmlWebpackPlugin = require('html-webpack-plugin')
9 | const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
10 | const portfinder = require('portfinder')
11 |
12 | function resolve(dir) {
13 | return path.join(__dirname, '..', dir)
14 | }
15 |
16 | const HOST = process.env.HOST
17 | const PORT = process.env.PORT && Number(process.env.PORT)
18 |
19 | const devWebpackConfig = merge(baseWebpackConfig, {
20 | mode: 'development',
21 | module: {
22 | rules: utils.styleLoaders({
23 | sourceMap: config.dev.cssSourceMap,
24 | usePostCSS: true
25 | })
26 | },
27 | // cheap-module-eval-source-map is faster for development
28 | devtool: config.dev.devtool,
29 |
30 | // these devServer options should be customized in /config/index.js
31 | devServer: {
32 | clientLogLevel: 'warning',
33 | historyApiFallback: true,
34 | hot: true,
35 | compress: true,
36 | host: HOST || config.dev.host,
37 | port: PORT || config.dev.port,
38 | open: config.dev.autoOpenBrowser,
39 | overlay: config.dev.errorOverlay
40 | ? { warnings: false, errors: true }
41 | : false,
42 | publicPath: config.dev.assetsPublicPath,
43 | proxy: config.dev.proxyTable,
44 | quiet: true, // necessary for FriendlyErrorsPlugin
45 | watchOptions: {
46 | poll: config.dev.poll
47 | }
48 | },
49 | plugins: [
50 | new webpack.DefinePlugin({
51 | 'process.env': require('../config/dev.env')
52 | }),
53 | new webpack.HotModuleReplacementPlugin(),
54 | // https://github.com/ampedandwired/html-webpack-plugin
55 | new HtmlWebpackPlugin({
56 | filename: 'index.html',
57 | template: 'index.html',
58 | inject: true,
59 | favicon: resolve('favicon.ico'),
60 | title: 'gospider',
61 | path: config.dev.assetsPublicPath + config.dev.assetsSubDirectory
62 | })
63 | ]
64 | })
65 |
66 | module.exports = new Promise((resolve, reject) => {
67 | portfinder.basePort = process.env.PORT || config.dev.port
68 | portfinder.getPort((err, port) => {
69 | if (err) {
70 | reject(err)
71 | } else {
72 | // publish the new Port, necessary for e2e tests
73 | process.env.PORT = port
74 | // add port to devServer config
75 | devWebpackConfig.devServer.port = port
76 |
77 | // Add FriendlyErrorsPlugin
78 | devWebpackConfig.plugins.push(
79 | new FriendlyErrorsPlugin({
80 | compilationSuccessInfo: {
81 | messages: [
82 | `Your application is running here: http://${
83 | devWebpackConfig.devServer.host
84 | }:${port}`
85 | ]
86 | },
87 | onErrors: config.dev.notifyOnErrors
88 | ? utils.createNotifierCallback()
89 | : undefined
90 | })
91 | )
92 |
93 | resolve(devWebpackConfig)
94 | }
95 | })
96 | })
97 |
--------------------------------------------------------------------------------
/web/static/src/views/exportdb/add.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | {{$t('exportdb.add')}}
27 |
28 |
29 |
30 |
31 |
32 |
33 |
73 |
--------------------------------------------------------------------------------
/web/static/build/webpack.base.conf.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const path = require('path')
3 | const utils = require('./utils')
4 | const config = require('../config')
5 | const { VueLoaderPlugin } = require('vue-loader')
6 | const vueLoaderConfig = require('./vue-loader.conf')
7 |
8 | function resolve(dir) {
9 | return path.join(__dirname, '..', dir)
10 | }
11 |
12 | const createLintingRule = () => ({
13 | test: /\.(js|vue)$/,
14 | loader: 'eslint-loader',
15 | enforce: 'pre',
16 | include: [resolve('src'), resolve('test')],
17 | options: {
18 | formatter: require('eslint-friendly-formatter'),
19 | emitWarning: !config.dev.showEslintErrorsInOverlay
20 | }
21 | })
22 |
23 | module.exports = {
24 | context: path.resolve(__dirname, '../'),
25 | entry: {
26 | app: './src/main.js'
27 | },
28 | output: {
29 | path: config.build.assetsRoot,
30 | filename: '[name].js',
31 | publicPath:
32 | process.env.NODE_ENV === 'production'
33 | ? config.build.assetsPublicPath
34 | : config.dev.assetsPublicPath
35 | },
36 | resolve: {
37 | extensions: ['.js', '.vue', '.json'],
38 | alias: {
39 | '@': resolve('src')
40 | }
41 | },
42 | module: {
43 | rules: [
44 | ...(config.dev.useEslint ? [createLintingRule()] : []),
45 | {
46 | test: /\.vue$/,
47 | loader: 'vue-loader',
48 | options: vueLoaderConfig
49 | },
50 | {
51 | test: /\.js$/,
52 | loader: 'babel-loader?cacheDirectory',
53 | include: [
54 | resolve('src'),
55 | resolve('test'),
56 | resolve('node_modules/webpack-dev-server/client')
57 | ]
58 | },
59 | {
60 | test: /\.svg$/,
61 | loader: 'svg-sprite-loader',
62 | include: [resolve('src/icons')],
63 | options: {
64 | symbolId: 'icon-[name]'
65 | }
66 | },
67 | {
68 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
69 | loader: 'url-loader',
70 | exclude: [resolve('src/icons')],
71 | options: {
72 | limit: 10000,
73 | name: utils.assetsPath('img/[name].[hash:7].[ext]')
74 | }
75 | },
76 | {
77 | test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
78 | loader: 'url-loader',
79 | options: {
80 | limit: 10000,
81 | name: utils.assetsPath('media/[name].[hash:7].[ext]')
82 | }
83 | },
84 | {
85 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
86 | loader: 'url-loader',
87 | options: {
88 | limit: 10000,
89 | name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
90 | }
91 | }
92 | ]
93 | },
94 | plugins: [new VueLoaderPlugin()],
95 | node: {
96 | // prevent webpack from injecting useless setImmediate polyfill because Vue
97 | // source contains it (although only uses it if it's native).
98 | setImmediate: false,
99 | // prevent webpack from injecting mocks to Node native modules
100 | // that does not make sense for the client
101 | dgram: 'empty',
102 | fs: 'empty',
103 | net: 'empty',
104 | tls: 'empty',
105 | child_process: 'empty'
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/web/static/config/index.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | // Template version: 1.2.6
3 | // see http://vuejs-templates.github.io/webpack for documentation.
4 |
5 | const path = require('path')
6 |
7 | module.exports = {
8 | dev: {
9 | // Paths
10 | assetsSubDirectory: 'static',
11 | assetsPublicPath: '/admin/',
12 | proxyTable: {},
13 |
14 | // Various Dev Server settings
15 |
16 | // can be overwritten by process.env.HOST
17 | // if you want dev by ip, please set host: '0.0.0.0'
18 | host: 'localhost',
19 | port: 3000, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined
20 | autoOpenBrowser: true,
21 | errorOverlay: true,
22 | notifyOnErrors: false,
23 | poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions-
24 |
25 | // Use Eslint Loader?
26 | // If true, your code will be linted during bundling and
27 | // linting errors and warnings will be shown in the console.
28 | useEslint: true,
29 | // If true, eslint errors and warnings will also be shown in the error overlay
30 | // in the browser.
31 | showEslintErrorsInOverlay: false,
32 |
33 | /**
34 | * Source Maps
35 | */
36 |
37 | // https://webpack.js.org/configuration/devtool/#development
38 | devtool: 'cheap-source-map',
39 |
40 | // CSS Sourcemaps off by default because relative paths are "buggy"
41 | // with this option, according to the CSS-Loader README
42 | // (https://github.com/webpack/css-loader#sourcemaps)
43 | // In our experience, they generally work as expected,
44 | // just be aware of this issue when enabling this option.
45 | cssSourceMap: false
46 | },
47 |
48 | build: {
49 | // Template for index.html
50 | index: path.resolve(__dirname, '../dist/index.html'),
51 |
52 | // Paths
53 | assetsRoot: path.resolve(__dirname, '../dist'),
54 | assetsSubDirectory: 'static',
55 |
56 | /**
57 | * You can set by youself according to actual condition
58 | * You will need to set this if you plan to deploy your site under a sub path,
59 | * for example GitHub pages. If you plan to deploy your site to https://foo.github.io/bar/,
60 | * then assetsPublicPath should be set to "/bar/".
61 | * In most cases please use '/' !!!
62 | */
63 | assetsPublicPath: '/admin/', // If you are deployed on the root path, please use '/'
64 |
65 | /**
66 | * Source Maps
67 | */
68 | productionSourceMap: false,
69 | // https://webpack.js.org/configuration/devtool/#production
70 | devtool: 'source-map',
71 |
72 | // Gzip off by default as many popular static hosts such as
73 | // Surge or Netlify already gzip all static assets for you.
74 | // Before setting to `true`, make sure to:
75 | // npm install --save-dev compression-webpack-plugin
76 | productionGzip: false,
77 | productionGzipExtensions: ['js', 'css'],
78 |
79 | // Run the build command with an extra argument to
80 | // View the bundle analyzer report after build finishes:
81 | // `npm run build:prod --report`
82 | // Set to `true` or `false` to always turn it on or off
83 | bundleAnalyzerReport: process.env.npm_config_report || false,
84 |
85 | // `npm run build:prod --generate_report`
86 | generateAnalyzerReport: process.env.npm_config_generate_report || false
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/web/static/src/store/modules/user.js:
--------------------------------------------------------------------------------
1 | import { loginByUsername, getUserInfo } from '@/api/login'
2 | import { getToken, setToken, removeToken } from '@/utils/auth'
3 |
4 | const user = {
5 | state: {
6 | user: '',
7 | status: '',
8 | code: '',
9 | token: getToken(),
10 | name: '',
11 | avatar: '',
12 | introduction: '',
13 | roles: [],
14 | setting: {
15 | articlePlatform: []
16 | }
17 | },
18 |
19 | mutations: {
20 | SET_CODE: (state, code) => {
21 | state.code = code
22 | },
23 | SET_TOKEN: (state, token) => {
24 | state.token = token
25 | },
26 | SET_INTRODUCTION: (state, introduction) => {
27 | state.introduction = introduction
28 | },
29 | SET_SETTING: (state, setting) => {
30 | state.setting = setting
31 | },
32 | SET_STATUS: (state, status) => {
33 | state.status = status
34 | },
35 | SET_NAME: (state, name) => {
36 | state.name = name
37 | },
38 | SET_AVATAR: (state, avatar) => {
39 | state.avatar = avatar
40 | },
41 | SET_ROLES: (state, roles) => {
42 | state.roles = roles
43 | }
44 | },
45 |
46 | actions: {
47 | // 用户名登录
48 | LoginByUsername({ commit }, userInfo) {
49 | const username = userInfo.username.trim()
50 | return new Promise((resolve, reject) => {
51 | loginByUsername(username, userInfo.password).then(response => {
52 | const data = response.data
53 | commit('SET_TOKEN', data.token)
54 | setToken(response.data.token)
55 | resolve()
56 | }).catch(error => {
57 | reject(error)
58 | })
59 | })
60 | },
61 |
62 | // 获取用户信息
63 | GetUserInfo({ commit, state }) {
64 | return new Promise((resolve, reject) => {
65 | getUserInfo(state.token).then(response => {
66 | if (!response.data) { // 由于mockjs 不支持自定义状态码只能这样hack
67 | reject('error')
68 | }
69 | const data = response.data
70 |
71 | if (data.roles && data.roles.length > 0) { // 验证返回的roles是否是一个非空数组
72 | commit('SET_ROLES', data.roles)
73 | } else {
74 | reject('getInfo: roles must be a non-null array !')
75 | }
76 |
77 | commit('SET_NAME', data.name)
78 | commit('SET_AVATAR', data.avatar)
79 | commit('SET_INTRODUCTION', data.introduction)
80 | resolve(response)
81 | }).catch(error => {
82 | reject(error)
83 | })
84 | })
85 | },
86 |
87 | // 登出
88 | LogOut({ commit }) {
89 | return new Promise(resolve => {
90 | commit('SET_TOKEN', '')
91 | commit('SET_ROLES', [])
92 | removeToken()
93 | resolve()
94 | })
95 | },
96 |
97 | // 前端 登出
98 | FedLogOut({ commit }) {
99 | return new Promise(resolve => {
100 | commit('SET_TOKEN', '')
101 | removeToken()
102 | resolve()
103 | })
104 | },
105 |
106 | // 动态修改权限
107 | ChangeRoles({ commit }, role) {
108 | return new Promise(resolve => {
109 | commit('SET_TOKEN', role)
110 | setToken(role)
111 | getUserInfo(role).then(response => {
112 | const data = response.data
113 | commit('SET_ROLES', data.roles)
114 | commit('SET_NAME', data.name)
115 | commit('SET_AVATAR', data.avatar)
116 | commit('SET_INTRODUCTION', data.introduction)
117 | resolve()
118 | })
119 | })
120 | }
121 | }
122 | }
123 |
124 | export default user
125 |
--------------------------------------------------------------------------------
/web/static/src/components/ErrorLog/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | Msg:
19 | {{ scope.row.err.message }}
20 |
21 |
22 |
23 | Info:
24 | {{scope.row.vm.$vnode.tag}} error in {{scope.row.info}}
25 |
26 |
27 |
28 | Url:
29 | {{scope.row.url}}
30 |
31 |
32 |
33 |
34 |
35 | {{ scope.row.err.stack}}
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
59 |
60 |
78 |
--------------------------------------------------------------------------------
/web/static/src/styles/index.scss:
--------------------------------------------------------------------------------
1 | @import './variables.scss';
2 | @import './mixin.scss';
3 | @import './transition.scss';
4 | @import './element-ui.scss';
5 | @import './sidebar.scss';
6 | @import './btn.scss';
7 |
8 | body {
9 | height: 100%;
10 | -moz-osx-font-smoothing: grayscale;
11 | -webkit-font-smoothing: antialiased;
12 | text-rendering: optimizeLegibility;
13 | font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
14 | }
15 |
16 | label {
17 | font-weight: 700;
18 | }
19 |
20 | html {
21 | height: 100%;
22 | box-sizing: border-box;
23 | }
24 |
25 | #app{
26 | height: 100%;
27 | }
28 |
29 | *,
30 | *:before,
31 | *:after {
32 | box-sizing: inherit;
33 | }
34 |
35 | .no-padding {
36 | padding: 0px !important;
37 | }
38 |
39 | .padding-content {
40 | padding: 4px 0;
41 | }
42 |
43 | a:focus,
44 | a:active {
45 | outline: none;
46 | }
47 |
48 | a,
49 | a:focus,
50 | a:hover {
51 | cursor: pointer;
52 | color: inherit;
53 | text-decoration: none;
54 | }
55 |
56 | div:focus{
57 | outline: none;
58 | }
59 |
60 | .fr {
61 | float: right;
62 | }
63 |
64 | .fl {
65 | float: left;
66 | }
67 |
68 | .pr-5 {
69 | padding-right: 5px;
70 | }
71 |
72 | .pl-5 {
73 | padding-left: 5px;
74 | }
75 |
76 | .block {
77 | display: block;
78 | }
79 |
80 | .pointer {
81 | cursor: pointer;
82 | }
83 |
84 | .inlineBlock {
85 | display: block;
86 | }
87 |
88 | .clearfix {
89 | &:after {
90 | visibility: hidden;
91 | display: block;
92 | font-size: 0;
93 | content: " ";
94 | clear: both;
95 | height: 0;
96 | }
97 | }
98 |
99 | code {
100 | background: #eef1f6;
101 | padding: 15px 16px;
102 | margin-bottom: 20px;
103 | display: block;
104 | line-height: 36px;
105 | font-size: 15px;
106 | font-family: "Source Sans Pro", "Helvetica Neue", Arial, sans-serif;
107 | a {
108 | color: #337ab7;
109 | cursor: pointer;
110 | &:hover {
111 | color: rgb(32, 160, 255);
112 | }
113 | }
114 | }
115 |
116 | .warn-content{
117 | background: rgba(66,185,131,.1);
118 | border-radius: 2px;
119 | padding: 16px;
120 | padding: 1rem;
121 | line-height: 1.6rem;
122 | word-spacing: .05rem;
123 | a{
124 | color: #42b983;
125 | font-weight: 600;
126 | }
127 | }
128 |
129 | //main-container全局样式
130 | .app-container {
131 | padding: 20px;
132 | }
133 |
134 | .components-container {
135 | margin: 30px 50px;
136 | position: relative;
137 | }
138 |
139 | .pagination-container {
140 | margin-top: 30px;
141 | }
142 |
143 | .text-center {
144 | text-align: center
145 | }
146 |
147 | .sub-navbar {
148 | height: 50px;
149 | line-height: 50px;
150 | position: relative;
151 | width: 100%;
152 | text-align: right;
153 | padding-right: 20px;
154 | transition: 600ms ease position;
155 | background: linear-gradient(90deg, rgba(32, 182, 249, 1) 0%, rgba(32, 182, 249, 1) 0%, rgba(33, 120, 241, 1) 100%, rgba(33, 120, 241, 1) 100%);
156 | .subtitle {
157 | font-size: 20px;
158 | color: #fff;
159 | }
160 | &.draft {
161 | background: #d0d0d0;
162 | }
163 | &.deleted {
164 | background: #d0d0d0;
165 | }
166 | }
167 |
168 | .link-type,
169 | .link-type:focus {
170 | color: #337ab7;
171 | cursor: pointer;
172 | &:hover {
173 | color: rgb(32, 160, 255);
174 | }
175 | }
176 |
177 | .filter-container {
178 | padding-bottom: 10px;
179 | .filter-item {
180 | display: inline-block;
181 | vertical-align: middle;
182 | margin-bottom: 10px;
183 | }
184 | }
185 |
186 | //refine vue-multiselect plugin
187 | .multiselect {
188 | line-height: 16px;
189 | }
190 |
191 | .multiselect--active {
192 | z-index: 1000 !important;
193 | }
194 |
--------------------------------------------------------------------------------