├── .drone.yml ├── .envrc.gpg ├── .gcloudignore ├── .gitignore ├── README.md ├── app.yaml.gpg ├── credential.json.gpg ├── cron.yaml ├── db ├── config.go ├── db.go └── db_test.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── index.html ├── jwt └── jwt.go ├── mail ├── config.go ├── mail.go └── mail.html ├── main.go ├── model ├── beauty.go ├── post.go └── post_test.go └── ptt ├── api ├── api.go ├── api_test.go └── parser.go ├── ptt.go └── time.go /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | name: default 3 | 4 | steps: 5 | - name: test 6 | image: golang:1.13.1 7 | environment: 8 | MONGO_HOST: 9 | from_secret: MONGO_HOST 10 | MONGO_PORT: 11 | from_secret: MONGO_PORT 12 | MONGO_USER: 13 | from_secret: MONGO_USER 14 | MONGO_PASS: 15 | from_secret: MONGO_PASS 16 | SMTP_USER: 17 | from_secret: SMTP_USER 18 | SMTP_PWD: 19 | from_secret: SMTP_PWD 20 | SMTP_HOST: 21 | from_secret: SMTP_HOST 22 | SMTP_PORT: 23 | from_secret: SMTP_PORT 24 | commands: 25 | - go test ./... 26 | 27 | - name: decrypt 28 | image: vladgh/gpg 29 | environment: 30 | GPG_PASSPHASE: 31 | from_secret: GPG_PASSPHASE 32 | commands: 33 | - echo $GPG_PASSPHASE | gpg --batch --yes --passphrase-fd 0 app.yaml.gpg 34 | - echo $GPG_PASSPHASE | gpg --batch --yes --passphrase-fd 0 credential.json.gpg 35 | 36 | - name: deploy 37 | image: google/cloud-sdk:alpine 38 | commands: 39 | - gcloud components install app-engine-go -q # install component "app-engine-go" 40 | - gcloud auth activate-service-account --key-file credential.json 41 | - gcloud config set project daily-beauty-209105 42 | - gcloud app deploy -q 43 | - gcloud app deploy cron.yaml -q 44 | -------------------------------------------------------------------------------- /.envrc.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LarryLuTW/ptt-daily-beauty/d595f6b1562fd24393e13479c81cfd4365d323ee/.envrc.gpg -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud Platform 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | # Binaries for programs and plugins 17 | *.exe 18 | *.exe~ 19 | *.dll 20 | *.so 21 | *.dylib 22 | # Test binary, build with `go test -c` 23 | *.test 24 | # Output of the go coverage tool, specifically when used with LiteIDE 25 | *.out -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | app.yaml 3 | credential.json 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## [Daily Beauty - 表特日報](https://daily-beauty.xyz) 2 | 3 | Daily Beauty 每晚十一點會自動蒐集 PTT 表特版前三名 4 | 5 | 這些都是 PTT 鄉民篩選過的,想要看的話就快點到[網站](https://daily-beauty.xyz)上訂閱吧~~~ 6 | 7 | ![](https://i.imgur.com/RdNBuie.png) 8 | 9 | ## Deployment 10 | 11 | ### Setup GCP App Engine 12 | 13 | put your app engine credential here 14 | 15 | ```js 16 | // deploy-en/credential.json 17 | { 18 | "type": "service_account", 19 | "project_id": "YOUR_PROJECT_ID", 20 | "private_key_id": "YOUR_PRIVATE_KEY_ID", 21 | "private_key": "YOUR_PRIVATE_KEY", 22 | // ... 23 | } 24 | ``` 25 | -------------------------------------------------------------------------------- /app.yaml.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LarryLuTW/ptt-daily-beauty/d595f6b1562fd24393e13479c81cfd4365d323ee/app.yaml.gpg -------------------------------------------------------------------------------- /credential.json.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LarryLuTW/ptt-daily-beauty/d595f6b1562fd24393e13479c81cfd4365d323ee/credential.json.gpg -------------------------------------------------------------------------------- /cron.yaml: -------------------------------------------------------------------------------- 1 | # https://cloud.google.com/appengine/docs/flexible/python/scheduling-jobs-with-cron-yaml 2 | cron: 3 | - description: 'send daily beauty email' 4 | url: /publish 5 | timezone: Asia/Taipei 6 | schedule: every day 23:00 7 | retry_parameters: 8 | job_retry_limit: 5 9 | min_backoff_seconds: 2 10 | max_doublings: 5 11 | -------------------------------------------------------------------------------- /db/config.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import "os" 4 | 5 | var ( 6 | host = os.Getenv("MONGO_HOST") 7 | port = os.Getenv("MONGO_PORT") 8 | user = os.Getenv("MONGO_USER") 9 | pass = os.Getenv("MONGO_PASS") 10 | ) 11 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/mongodb/mongo-go-driver/mongo" 8 | ) 9 | 10 | const ( 11 | dbName = "daily-beauty" 12 | collName = "emails" 13 | ) 14 | 15 | var ctx = context.Background() 16 | var emailsCol *mongo.Collection 17 | 18 | // init mongodb connection 19 | func init() { 20 | url := fmt.Sprintf("mongodb://%s:%s@%s:%s/%s", user, pass, host, port, dbName) 21 | client, err := mongo.NewClient(url) 22 | if err != nil { 23 | panic(err) 24 | } 25 | err = client.Connect(ctx) 26 | if err != nil { 27 | panic(err) 28 | } 29 | emailsCol = client.Database(dbName).Collection(collName) 30 | } 31 | 32 | func checkExist(email string) (bool, error) { 33 | filter := map[string]string{"email": email} 34 | n, err := emailsCol.Count(ctx, filter) 35 | return n != 0, err 36 | } 37 | 38 | // InsertAEmail save a email into the database 39 | func InsertAEmail(email string) error { 40 | // check email duplicate 41 | isExist, err := checkExist(email) 42 | if err != nil { 43 | return err 44 | } 45 | if isExist { 46 | return nil 47 | } 48 | 49 | data := map[string]string{"email": email} 50 | _, err = emailsCol.InsertOne(ctx, data) 51 | // if success, err will be nil 52 | return err 53 | } 54 | 55 | // RemoveAEmail removes a email from database 56 | func RemoveAEmail(email string) error { 57 | filter := map[string]string{"email": email} 58 | _, err := emailsCol.DeleteOne(ctx, filter) 59 | return err 60 | } 61 | 62 | // GetEmails get all emails in the database 63 | func GetEmails() ([]string, error) { 64 | cur, err := emailsCol.Find(ctx, nil) 65 | if err != nil { 66 | return nil, err 67 | } 68 | defer cur.Close(ctx) 69 | 70 | emails := []string{} 71 | for cur.Next(ctx) { 72 | raw, err := cur.DecodeBytes() 73 | if err != nil { 74 | return nil, err 75 | } 76 | email := raw.Lookup("email").StringValue() 77 | emails = append(emails, email) 78 | } 79 | return emails, nil 80 | } 81 | -------------------------------------------------------------------------------- /db/db_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func countEmailInSlice(s []string, email string) (count int) { 8 | for _, e := range s { 9 | if e == email { 10 | count++ 11 | } 12 | } 13 | return count 14 | } 15 | 16 | func TestInsertANewEmail(t *testing.T) { 17 | newEmail := "pudding850806+100@gmail.com" 18 | err := InsertAEmail(newEmail) 19 | if err != nil { 20 | t.Error(err) 21 | } 22 | emails, err := GetEmails() 23 | if err != nil { 24 | t.Error(err) 25 | } 26 | if countEmailInSlice(emails, newEmail) == 0 { 27 | t.Error("new email should be in db") 28 | } 29 | RemoveAEmail(newEmail) 30 | } 31 | 32 | func TestInsertADuplicateEmail(t *testing.T) { 33 | newEmail := "pudding850806+101@gmail.com" 34 | err := InsertAEmail(newEmail) 35 | if err != nil { 36 | t.Error(err) 37 | } 38 | err = InsertAEmail(newEmail) 39 | if err != nil { 40 | t.Error(err) 41 | } 42 | emails, err := GetEmails() 43 | if err != nil { 44 | t.Error(err) 45 | } 46 | if countEmailInSlice(emails, newEmail) != 1 { 47 | t.Errorf("there should be only a %s in db", newEmail) 48 | } 49 | RemoveAEmail(newEmail) 50 | } 51 | 52 | func TestRemoveAEmail(t *testing.T) { 53 | newEmail := "pudding850806+102@gmail.com" 54 | err := InsertAEmail(newEmail) 55 | if err != nil { 56 | t.Error(err) 57 | } 58 | err = RemoveAEmail(newEmail) 59 | if err != nil { 60 | t.Error(err) 61 | } 62 | emails, err := GetEmails() 63 | if err != nil { 64 | t.Error(err) 65 | } 66 | if countEmailInSlice(emails, newEmail) != 0 { 67 | t.Errorf("there should be NO %s in db", newEmail) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | deploy_en: 5 | container_name: PTTDB_deploy_en 6 | build: ./deploy-en 7 | tty: true 8 | volumes: 9 | - '${PWD}:/src' 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module main 2 | 3 | require ( 4 | github.com/PuerkitoBio/goquery v1.5.0 5 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 6 | github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 // indirect 7 | github.com/gin-gonic/gin v1.3.0 8 | github.com/go-stack/stack v1.8.0 // indirect 9 | github.com/golang/protobuf v1.2.0 // indirect 10 | github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect 11 | github.com/jmoiron/jsonq v0.0.0-20150511023944-e874b168d07e 12 | github.com/mattn/go-isatty v0.0.4 // indirect 13 | github.com/mongodb/mongo-go-driver v0.0.18 14 | github.com/ugorji/go/codec v0.0.0-20181125142609-66da5d561eb7 // indirect 15 | github.com/vjeantet/jodaTime v0.0.0-20170816150230-be924ce213fb 16 | github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c // indirect 17 | github.com/xdg/stringprep v1.0.0 // indirect 18 | golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869 // indirect 19 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a 20 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect 21 | golang.org/x/text v0.3.0 // indirect 22 | gopkg.in/go-playground/validator.v8 v8.18.2 // indirect 23 | gopkg.in/yaml.v2 v2.2.1 // indirect 24 | ) 25 | 26 | go 1.13 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk= 2 | github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= 3 | github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o= 4 | github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 5 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 7 | github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 h1:AzN37oI0cOS+cougNAV9szl6CVoj2RYwzS3DpUQNtlY= 8 | github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= 9 | github.com/gin-gonic/gin v1.3.0 h1:kCmZyPklC0gVdL728E6Aj20uYBJV93nj/TkwBTKhFbs= 10 | github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= 11 | github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= 12 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 13 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 14 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 15 | github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w= 16 | github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 17 | github.com/jmoiron/jsonq v0.0.0-20150511023944-e874b168d07e h1:ZZCvgaRDZg1gC9/1xrsgaJzQUCQgniKtw0xjWywWAOE= 18 | github.com/jmoiron/jsonq v0.0.0-20150511023944-e874b168d07e/go.mod h1:+rHyWac2R9oAZwFe1wGY2HBzFJJy++RHBg1cU23NkD8= 19 | github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= 20 | github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 21 | github.com/mongodb/mongo-go-driver v0.0.18 h1:VdItX4LNE6/vXaKPeQtAs7K7zp6N7RyeN3fEeWEt7+Y= 22 | github.com/mongodb/mongo-go-driver v0.0.18/go.mod h1:NK/HWDIIZkaYsnYa0hmtP443T5ELr0KDecmIioVuuyU= 23 | github.com/ugorji/go/codec v0.0.0-20181125142609-66da5d561eb7 h1:1bUo5QMaUTAFAGnXBin93icq1hqP27fbWVZb6vddDec= 24 | github.com/ugorji/go/codec v0.0.0-20181125142609-66da5d561eb7/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= 25 | github.com/vjeantet/jodaTime v0.0.0-20170816150230-be924ce213fb h1:9Cx/q/wd5p+BjCDBjY+rauPbwoS+chrnQ9MKMUtv/hs= 26 | github.com/vjeantet/jodaTime v0.0.0-20170816150230-be924ce213fb/go.mod h1:XK4iy/zfkdRGe+lWQYwmebWh0IIMIe6+wi3APUAiCJ0= 27 | github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk= 28 | github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= 29 | github.com/xdg/stringprep v1.0.0 h1:d9X0esnoa3dFsV0FG35rAT0RIhYFlPq7MiP+DW89La0= 30 | github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= 31 | golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869 h1:kkXA53yGe04D0adEYJwEVQjeBppL01Exg+fnMjfUraU= 32 | golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 33 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 34 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a h1:gOpx8G595UYyvj8UK4+OFyY4rx037g3fmfhe5SasG3U= 35 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 36 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ= 37 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 38 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 39 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 40 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 41 | gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ= 42 | gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= 43 | gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= 44 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 45 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Daily Beauty 表特日報 7 | 8 | 13 | 17 | 18 | 71 | 72 | 73 |
74 |
75 |
76 |

77 | Daily Beauty
78 | 表特日報 79 |

80 |
81 |
82 |

Daily Beauty 就跟它的名字一樣,是一個有關表特的日報

83 |

每晚十一點會自動蒐集 PTT 表特版前三名送到你的信箱

84 |

這些都是經過鄉民篩選的,可以說是群眾智慧的展現

85 |

想要的話就快點訂閱吧~~~

86 |
87 | 88 | 96 |
97 |
98 | 99 |
100 |
101 |
102 | 103 | 104 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /jwt/jwt.go: -------------------------------------------------------------------------------- 1 | package jwt 2 | 3 | import ( 4 | "fmt" 5 | 6 | jwtGo "github.com/dgrijalva/jwt-go" 7 | ) 8 | 9 | var secret = []byte("I am Larry Lu, a Gopher.") 10 | 11 | // ParseToken parse a token string and get email 12 | func ParseToken(tokenStr string) (email string, err error) { 13 | // ref: https://godoc.org/github.com/dgrijalva/jwt-go#example-Parse--Hmac 14 | keyFunc := func(token *jwtGo.Token) (interface{}, error) { 15 | if _, ok := token.Method.(*jwtGo.SigningMethodHMAC); !ok { 16 | return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) 17 | } 18 | return secret, nil 19 | } 20 | 21 | token, err := jwtGo.Parse(tokenStr, keyFunc) 22 | if claims, ok := token.Claims.(jwtGo.MapClaims); ok && token.Valid { 23 | email := claims["email"].(string) 24 | return email, nil 25 | } else { 26 | return "", err 27 | } 28 | } 29 | 30 | // NewToken accept a email and generate a token 31 | func NewToken(email string) (tokenStr string) { 32 | // ref: https://godoc.org/github.com/dgrijalva/jwt-go#example-New--Hmac 33 | payload := jwtGo.MapClaims{"email": email} 34 | token := jwtGo.NewWithClaims(jwtGo.SigningMethodHS256, payload) 35 | tokenStr, _ = token.SignedString(secret) 36 | return tokenStr 37 | } 38 | -------------------------------------------------------------------------------- /mail/config.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import "os" 4 | 5 | var ( 6 | host = os.Getenv("SMTP_HOST") 7 | port = os.Getenv("SMTP_PORT") 8 | user = os.Getenv("SMTP_USER") 9 | pwd = os.Getenv("SMTP_PWD") 10 | ) 11 | -------------------------------------------------------------------------------- /mail/mail.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "net/smtp" 8 | 9 | "main/model" 10 | ) 11 | 12 | const ( 13 | name = "Daily Beauty" 14 | from = "service@daily-beauty.xyz" 15 | ) 16 | 17 | var auth = smtp.PlainAuth("", user, pwd, host) 18 | 19 | func createMsg(to, subject, html string) []byte { 20 | msg := "" 21 | msg += fmt.Sprintf("To: %s\r\n", to) 22 | msg += fmt.Sprintf("From: %s <%s>\r\n", name, from) 23 | msg += fmt.Sprintf("Subject: %s\r\n", subject) 24 | 25 | msg += "MIME-version: 1.0;\r\n" 26 | msg += `Content-Type: text/html; charset="UTF-8"` + "\r\n" 27 | 28 | msg += "\r\n" 29 | msg += fmt.Sprintf("%s\r\n", html) 30 | return []byte(msg) 31 | } 32 | 33 | // Send sends the html to the receiver 34 | func Send(to, subject, html string) { 35 | addr := fmt.Sprintf("%s:%s", host, port) 36 | msg := createMsg(to, subject, html) 37 | smtp.SendMail(addr, auth, from, []string{to}, msg) 38 | } 39 | 40 | func reverse(bs []model.Beauty) []model.Beauty { 41 | n := len(bs) 42 | reversedBs := make([]model.Beauty, n) 43 | for i, b := range bs { 44 | reversedBs[n-i-1] = b 45 | } 46 | return reversedBs 47 | } 48 | 49 | // GenerateHTML generates a html from beauties slice 50 | func GenerateHTML(beauties []model.Beauty, randomBeauty model.Beauty, token string) string { 51 | tmpl := template.Must(template.ParseFiles("./mail/mail.html")) 52 | 53 | data := map[string]interface{}{ 54 | "Beauties": reverse(beauties), 55 | "RandomBeauty": randomBeauty, 56 | "Token": token, 57 | } 58 | 59 | var b bytes.Buffer 60 | tmpl.Execute(&b, data) 61 | html := b.String() 62 | 63 | return html 64 | } 65 | -------------------------------------------------------------------------------- /mail/mail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 72 | 73 | 74 |

Daily Beauty 表特日報

75 |
 76 |       前陣子在忙鐵人賽,剛好 PTT 又改了一些東西,所以大概壞了兩個多月,如果這兩個月以來你已經洗心革面不想再看妹了,那可以到下方按取消訂閱~
 77 |     
78 |
本日精選
79 | {{ range $.Beauties }} 80 | 81 |

{{ .Title }}({{ .NImage }}圖)

82 | 83 |
84 | {{ end }} 85 | 86 |
隨機推薦
87 | 88 |

{{ $.RandomBeauty.Title }}({{ $.RandomBeauty.NImage }}圖)

89 | 90 |
91 | 92 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "math/rand" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/gin-gonic/gin" 14 | "github.com/jmoiron/jsonq" 15 | "github.com/vjeantet/jodaTime" 16 | 17 | "main/db" 18 | "main/jwt" 19 | "main/mail" 20 | "main/ptt" 21 | ) 22 | 23 | func sendDailyBeauty(subscribers []string, isTest bool) { 24 | log.Println("getting daily beauty...") 25 | // TODO: do parallelly 26 | beauties, err := ptt.FetchBeauties() 27 | if err != nil { 28 | panic(err) 29 | } 30 | 31 | randomBeauty, err := ptt.FetchRandomBeauty() 32 | if err != nil { 33 | panic(err) 34 | } 35 | 36 | loc, _ := time.LoadLocation("Asia/Taipei") 37 | date := jodaTime.Format("YYYY-MM-dd", time.Now().In(loc)) 38 | subject := fmt.Sprintf("表特日報-%s", date) 39 | 40 | if isTest { 41 | subject += " " + strconv.Itoa(rand.Int()) 42 | } 43 | 44 | log.Println("sending...") 45 | for _, to := range subscribers { 46 | token := jwt.NewToken(to) 47 | html := mail.GenerateHTML(beauties, randomBeauty, token) 48 | mail.Send(to, subject, html) 49 | log.Printf("Send to '%s' success", to) 50 | time.Sleep(200 * time.Millisecond) 51 | } 52 | 53 | log.Println("Finish") 54 | } 55 | 56 | func testHandler(c *gin.Context) { 57 | toMails := []string{"flaviogptdb@gmail.com"} 58 | sendDailyBeauty(toMails, true) 59 | log.Printf("Test successfully\n") 60 | c.String(200, "Test successfully") 61 | } 62 | 63 | func publishHandler(c *gin.Context) { 64 | toMails, err := db.GetEmails() 65 | if err != nil { 66 | panic(err) 67 | } 68 | 69 | sendDailyBeauty(toMails, false) 70 | log.Printf("Publish to %d users successfully\n", len(toMails)) 71 | c.String(200, "Publish successfully") 72 | } 73 | 74 | func subscribeHandler(c *gin.Context) { 75 | data := map[string]interface{}{} 76 | dec := json.NewDecoder(c.Request.Body) 77 | dec.Decode(&data) 78 | jq := jsonq.NewQuery(data) 79 | email, err := jq.String("email") 80 | if err != nil { 81 | panic(err) 82 | } 83 | db.InsertAEmail(email) 84 | } 85 | 86 | // api/unsubscribe?token={jwt_token} 87 | func unsubscribeHandler(c *gin.Context) { 88 | tokenStr := c.Query("token") 89 | email, err := jwt.ParseToken(tokenStr) 90 | 91 | if err != nil { 92 | c.AbortWithError(400, err) 93 | // TODO: render error to frontend 94 | return 95 | } 96 | 97 | db.RemoveAEmail(email) 98 | log.Printf("%s unsubscribe", email) 99 | c.String(200, "you(%s) have been unsubscribed from our mailing list", email) 100 | } 101 | 102 | func emailsHandler(c *gin.Context) { 103 | emails, err := db.GetEmails() 104 | if err != nil { 105 | panic(err) 106 | } 107 | c.JSON(200, emails) 108 | } 109 | 110 | func homePageHandler(c *gin.Context) { 111 | c.HTML(http.StatusOK, "index.html", nil) 112 | } 113 | 114 | // Redirect /ptt/redirect/M.1543991133.A.1A1 115 | // to https://www.ptt.cc/bbs/Beauty/M.1543991133.A.1A1.html 116 | func pttRedirectHandler(c *gin.Context) { 117 | baseURL := "https://www.ptt.cc/bbs/Beauty/" 118 | articleID := c.Param("articleID") 119 | location := fmt.Sprintf("%s%s.html", baseURL, articleID) 120 | c.Redirect(302, location) 121 | } 122 | 123 | func main() { 124 | r := gin.Default() 125 | r.GET("/test", testHandler) 126 | r.GET("/publish", publishHandler) 127 | 128 | r.POST("/api/subscribe", subscribeHandler) 129 | r.GET("/api/unsubscribe", unsubscribeHandler) 130 | r.GET("/api/emails", emailsHandler) 131 | 132 | r.GET("/ptt/redirect/:articleID", pttRedirectHandler) 133 | 134 | r.LoadHTMLFiles("index.html") 135 | r.GET("/", homePageHandler) 136 | 137 | port := os.Getenv("PORT") 138 | if port == "" { 139 | port = "8080" 140 | } 141 | 142 | log.Printf("listen on port %s", port) 143 | err := r.Run(":" + port) 144 | panic(err) 145 | } 146 | 147 | // TODO: analysis 轉網址 148 | // TODO: 禮拜幾標題變化 149 | // TODO: 下載所有圖片 150 | // TODO: 防止手動觸發 cron 151 | -------------------------------------------------------------------------------- /model/beauty.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // Beauty is a struct from getDailyBeauties api 4 | type Beauty struct { 5 | NVote int 6 | NImage int 7 | Title string 8 | Href string 9 | PreviewImg string 10 | } 11 | -------------------------------------------------------------------------------- /model/post.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/PuerkitoBio/goquery" 10 | ) 11 | 12 | // Post is a corresponding to a post on ptt 13 | type Post struct { 14 | Title string 15 | Href string 16 | NVote int 17 | Date time.Time 18 | } 19 | 20 | // fetchPreviewImg get the preview image of a post 21 | func fetchPreviewImg(p *Post) string { 22 | // TODO: handle error 23 | client := http.DefaultClient 24 | req, _ := http.NewRequest("GET", p.Href, nil) 25 | req.Header.Set("Cookie", "over18=1") 26 | 27 | res, _ := client.Do(req) 28 | doc, _ := goquery.NewDocumentFromResponse(res) 29 | 30 | imgSelector := `#main-content a[href$=".jpg"],a[href$=".png"],a[href$=".gif"]` 31 | imgURL, _ := doc.Find(imgSelector).Attr("href") 32 | return imgURL 33 | } 34 | 35 | // fetchImageAmount get the amount of images in a post 36 | func fetchImageAmount(p *Post) int { 37 | // TODO: handle error 38 | client := http.DefaultClient 39 | req, _ := http.NewRequest("GET", p.Href, nil) 40 | req.Header.Set("Cookie", "over18=1") 41 | 42 | res, _ := client.Do(req) 43 | doc, _ := goquery.NewDocumentFromResponse(res) 44 | 45 | doc.Find("div.push").Each(func(i int, s *goquery.Selection) { 46 | // remove push comment 47 | s.Remove() 48 | }) 49 | imgSelector := `#main-content a[href$=".jpg"],a[href$=".png"],a[href$=".gif"]` 50 | nImage := doc.Find(imgSelector).Size() 51 | return nImage 52 | } 53 | 54 | // "[正妹] 大橋未久" -> "大橋未久" 55 | func trimTitlePrefix(title string) string { 56 | return strings.TrimPrefix(title, "[正妹] ") 57 | } 58 | 59 | // transform https://www.ptt.cc/bbs/Beauty/M.1543991133.A.1A1.html 60 | // to https://daily-beauty.xyz/ptt/redirect/M.1543991133.A.1A1 61 | func transformURL(pttURL string) string { 62 | var articleID string 63 | fmt.Sscanf(pttURL, "https://www.ptt.cc/bbs/Beauty/%18s.html", &articleID) 64 | return fmt.Sprintf("https://daily-beauty.xyz/ptt/redirect/%s", articleID) 65 | } 66 | 67 | // ToBeauty transform a Post to a Beauty 68 | func (p *Post) ToBeauty() Beauty { 69 | previewImg := fetchPreviewImg(p) 70 | nImage := fetchImageAmount(p) 71 | return Beauty{ 72 | NVote: p.NVote, 73 | NImage: nImage, 74 | Title: trimTitlePrefix(p.Title), 75 | Href: transformURL(p.Href), 76 | PreviewImg: previewImg, 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /model/post_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestPostToBeauty(t *testing.T) { 9 | { 10 | p := Post{ 11 | Title: "[正妹] 覺得還不錯", 12 | Href: "https://www.ptt.cc/bbs/Beauty/M.1543280871.A.39A.html", 13 | NVote: 50, 14 | Date: time.Now(), 15 | } 16 | b := p.ToBeauty() 17 | if b.PreviewImg != "https://imgur.com/30XW9qD.jpg" { 18 | t.Error("preview image error") 19 | } 20 | if b.Title != "覺得還不錯" { 21 | t.Error("trim title error") 22 | } 23 | if b.Href != "https://daily-beauty.xyz/ptt/redirect/M.1543280871.A.39A" { 24 | t.Errorf("transform url error %s", b.Href) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ptt/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | 8 | "main/model" 9 | 10 | "github.com/PuerkitoBio/goquery" 11 | ) 12 | 13 | // FetchPageAmount get latest page number 14 | func FetchPageAmount() (int, error) { 15 | url := "https://www.ptt.cc/bbs/Beauty/index.html" 16 | 17 | client := http.DefaultClient 18 | req, _ := http.NewRequest("GET", url, nil) 19 | req.Header.Set("Cookie", "over18=1") 20 | 21 | res, _ := client.Do(req) 22 | doc, _ := goquery.NewDocumentFromResponse(res) 23 | 24 | prevPageSelector := "div.btn-group.btn-group-paging a:nth-child(2)" 25 | href, _ := doc.Find(prevPageSelector).Attr("href") 26 | 27 | var n int 28 | fmt.Sscanf(href, "/bbs/Beauty/index%d.html", &n) 29 | 30 | if n == 0 { 31 | return 0, errors.New("Cannot connect to PTT") 32 | } 33 | return n + 1, nil 34 | } 35 | 36 | // FetchPage get all posts in a page 37 | func FetchPage(prefix string, page int) ([]model.Post, error) { 38 | baseURL := "https://www.ptt.cc/bbs/Beauty/" 39 | url := fmt.Sprintf("%sindex%d.html", baseURL, page) 40 | 41 | // TODO: refactor HTTP client 42 | client := http.DefaultClient 43 | req, _ := http.NewRequest("GET", url, nil) 44 | req.Header.Set("Cookie", "over18=1") 45 | 46 | res, _ := client.Do(req) 47 | doc, err := goquery.NewDocumentFromResponse(res) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | posts := parseDoc2Posts(doc, prefix) 53 | return posts, nil 54 | } 55 | 56 | // Search use PTT search to get search result 57 | // sometimes PTT cache search result 58 | func Search(prefix string, page, recommend int) ([]model.Post, error) { 59 | // page from 1, 2, ... 60 | baseURL := "https://www.ptt.cc/bbs/Beauty/search" 61 | url := fmt.Sprintf("%s?page=%d&q=%s+recommend:%d", baseURL, page, prefix, recommend) 62 | 63 | client := http.DefaultClient 64 | req, _ := http.NewRequest("GET", url, nil) 65 | req.Header.Set("Cookie", "over18=1") 66 | 67 | res, _ := client.Do(req) 68 | doc, err := goquery.NewDocumentFromResponse(res) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | posts := parseDoc2Posts(doc, prefix) 74 | return posts, nil 75 | } 76 | -------------------------------------------------------------------------------- /ptt/api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestFetchPageAmount(t *testing.T) { 9 | nPage, err := FetchPageAmount() 10 | if nPage < 2733 { 11 | t.Errorf("nPage is wrong") 12 | } 13 | if err != nil { 14 | t.Error(nil) 15 | } 16 | } 17 | 18 | func TestFetchPage(t *testing.T) { 19 | prefix := "[" 20 | posts, err := FetchPage(prefix, 2733) 21 | if err != nil { 22 | t.Error(err) 23 | } 24 | if len(posts) == 0 { 25 | t.Errorf("posts should NOT be empty") 26 | } 27 | if !strings.HasPrefix(posts[0].Title, prefix) { 28 | t.Errorf("posts should be prefixed with %s", prefix) 29 | } 30 | } 31 | 32 | func TestSearch(t *testing.T) { 33 | prefix := "[" 34 | posts, err := Search(prefix, 1, 10) 35 | if err != nil { 36 | t.Error(err) 37 | } 38 | if len(posts) == 0 { 39 | t.Errorf("len(posts) should be 20") 40 | } 41 | if posts[0].NVote < 10 { 42 | t.Errorf("post.NVote should >= 10") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ptt/api/parser.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "main/model" 10 | 11 | "github.com/PuerkitoBio/goquery" 12 | "github.com/vjeantet/jodaTime" 13 | ) 14 | 15 | // parseNVote parses vote text to int 16 | // "50" => 50, "爆" => 100 17 | // "" => 0 18 | // "X7" => -1 19 | // there is no need to handle nVote <= 0 20 | // because they are filterer out when searching 21 | func parseNVote(nVoteText string) int { 22 | if nVoteText == "爆" { 23 | return 100 24 | } 25 | if nVoteText == "" { 26 | return 0 27 | } 28 | if strings.HasPrefix(nVoteText, "X") { 29 | return -1 30 | } 31 | nVote, _ := strconv.Atoi(nVoteText) 32 | return nVote 33 | } 34 | 35 | func parseDoc2Posts(doc *goquery.Document, prefix string) []model.Post { 36 | // TODO: remove 置頂文 37 | posts := make([]model.Post, 0, 20) 38 | doc.Find(".r-ent").Each(func(i int, el *goquery.Selection) { 39 | nVoteText := el.Find(".hl").Text() 40 | nVote := parseNVote(nVoteText) 41 | 42 | titleEl := el.Find(".title > a") 43 | title := titleEl.Text() 44 | 45 | if !strings.HasPrefix(title, prefix) { 46 | return 47 | } 48 | 49 | hrefText, _ := titleEl.Attr("href") 50 | href := "https://www.ptt.cc" + hrefText 51 | 52 | currentYear := time.Now().Year() 53 | mmdd := strings.TrimSpace(el.Find(".meta .date").Text()) 54 | dateText := fmt.Sprintf("%d/%s", currentYear, mmdd) 55 | date, _ := jodaTime.ParseInLocation("YYYY/M/dd", dateText, "Asia/Taipei") 56 | 57 | p := model.Post{ 58 | Title: title, 59 | Href: href, 60 | NVote: nVote, 61 | Date: date, 62 | } 63 | 64 | posts = append(posts, p) 65 | }) 66 | return posts 67 | } 68 | -------------------------------------------------------------------------------- /ptt/ptt.go: -------------------------------------------------------------------------------- 1 | package ptt 2 | 3 | import ( 4 | "main/model" 5 | "main/ptt/api" 6 | "math/rand" 7 | "sort" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | // TODO: split ptt api layer and utils layer 13 | func init() { 14 | rand.Seed(time.Now().UnixNano()) 15 | } 16 | 17 | func fetchYesterdayPosts() ([]model.Post, error) { 18 | prefix := "[正妹]" 19 | recentPosts := make([]model.Post, 0, 20) 20 | 21 | // get recent posts 22 | page, err := api.FetchPageAmount() 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | for ; ; page-- { 28 | posts, err := api.FetchPage(prefix, page) 29 | 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | recentPosts = append(recentPosts, posts...) 35 | oldestDate := recentPosts[len(recentPosts)-1].Date 36 | if isBeforeYesterday(oldestDate) { 37 | break 38 | } 39 | } 40 | 41 | // filter yesterday post 42 | yesterdayPosts := make([]model.Post, 0, 10) 43 | for _, p := range recentPosts { 44 | if isYesterday(p.Date) { 45 | yesterdayPosts = append(yesterdayPosts, p) 46 | } 47 | } 48 | 49 | return yesterdayPosts, nil 50 | } 51 | 52 | // FetchRandomBeauty randomly fetch a model.Beauty 53 | func FetchRandomBeauty() (model.Beauty, error) { 54 | prefix := "[正妹]" 55 | page := rand.Intn(40) + 11 // 11 ~ 50 56 | posts, err := api.Search(prefix, page, 99) 57 | 58 | if err != nil { 59 | return model.Beauty{}, err 60 | } 61 | 62 | idx := rand.Intn(len(posts)) // 0 ~ len(posts)-1 63 | p := posts[idx] 64 | b := p.ToBeauty() 65 | return b, nil 66 | } 67 | 68 | func getBestBeauties(posts []model.Post) []model.Beauty { 69 | sort.SliceStable(posts, func(i, j int) bool { 70 | return posts[i].NVote > posts[j].NVote 71 | }) 72 | 73 | nBeauty := 2 74 | champions := posts[:nBeauty] 75 | beauties := make([]model.Beauty, nBeauty) 76 | 77 | var wg sync.WaitGroup 78 | wg.Add(nBeauty) 79 | for i, p := range champions { 80 | go func(i int, p model.Post) { 81 | beauties[i] = p.ToBeauty() 82 | wg.Done() 83 | }(i, p) 84 | } 85 | wg.Wait() 86 | 87 | return beauties 88 | } 89 | 90 | // FetchBeauties send a request to get beauties from getDailyBeauties api 91 | func FetchBeauties() ([]model.Beauty, error) { 92 | posts, err := fetchYesterdayPosts() 93 | if err != nil { 94 | return nil, err 95 | } 96 | beauties := getBestBeauties(posts) 97 | return beauties, nil 98 | } 99 | -------------------------------------------------------------------------------- /ptt/time.go: -------------------------------------------------------------------------------- 1 | package ptt 2 | 3 | import "time" 4 | 5 | func isToday(t time.Time) bool { 6 | loc, _ := time.LoadLocation("Asia/Taipei") 7 | current := time.Now().In(loc) 8 | return t.YearDay() == current.YearDay() 9 | } 10 | 11 | func isYesterday(t time.Time) bool { 12 | // FIXME: 跨年 1/1 < 12/31 13 | loc, _ := time.LoadLocation("Asia/Taipei") 14 | current := time.Now().In(loc) 15 | return t.YearDay() == current.YearDay()-1 16 | } 17 | 18 | func isBeforeYesterday(t time.Time) bool { 19 | // FIXME: 跨年 1/1 < 12/31 20 | loc, _ := time.LoadLocation("Asia/Taipei") 21 | current := time.Now().In(loc) 22 | return t.YearDay() < current.YearDay()-1 23 | } 24 | --------------------------------------------------------------------------------