diff --git a/.github/workflows/run.yml b/.github/workflows/run.yml new file mode 100644 index 0000000..ee1c953 --- /dev/null +++ b/.github/workflows/run.yml @@ -0,0 +1,25 @@ +name: "Cronjob" +on: + schedule: + - cron: '15 */6 * * *' + release: + types: [published] + +jobs: + update-weather: + permissions: write-all + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Update articles + uses: huantt/article-listing@v1.0.0 + with: + template-file: 'README.md.template' + out-file: 'README.md' + - name: Commit + run: | + git config user.name github-actions + git config user.email github-actions@github.com + git add . + git commit -m "update" + git push origin main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa81aa9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +.test/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1c12a60 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.21-bullseye as builder + +ENV CGO_ENABLED=1 +ENV GOOS=linux +ENV GOARCH=amd64 +ENV GO111MODULE=on +WORKDIR /app +COPY go.mod . +COPY go.sum . +RUN go mod download +COPY . . +RUN go build -o app + +FROM ubuntu:22.04 +RUN apt-get update && apt-get install -y tzdata ca-certificates +RUN mkdir /app +WORKDIR /app +COPY --from=builder /app/app . +RUN ln -s /app/app /bin/articles-updater +ENTRYPOINT ["articles-updater", "update-articles"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9819429 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +## About +Collect your latest articles from sources such as [dev.to](https://dev.to), and then update the `README.md`. + +## Use GitHub Action to update your README + +**Step 1:** In your repository, create a file named `README.md.template`. + +**Step 2:** Write anything you want within the `README.md.template` file. + +**Step 3:** Embed one of the following entities within your `README.md.template`: + +- **Article listing:** +```shell +{{ template "article-list" .Articles }} +``` + +If you are familiar with Go templates, you have access to the `root` variable, which includes the following fields: + +- `Articles`: An array of Article. You can view the Article struct definition in [model/article.go](model/article.go). +- `Time`: Updated Time + +**Step 4**: Register Github Action +- Create a file `.github/workflows/update-articles.yml` in your repository. +```yml +name: "Cronjob" +on: +schedule: +- cron: '15 0 * * *' + +jobs: + update-articles: + permissions: write-all + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Generate README + uses: huantt/article-listing@v1.0.0 + with: + username: YOUR_USERNAME_ON_DEV_TO + template-file: 'README.md.template' + out-file: 'README.md' + limit: 5 + - name: Commit + run: | + git config user.name github-actions + git config user.email github-actions@github.com + git add . + git commit -m "update articles" + git push origin main +``` + +**Step 5**: Commit your change, then Github actions will run as your specified cron to update Articles into your README.md file + +## Below is my recent articles Jack collected from dev.to + + +- [Creating Dynamic README.md File](https://dev.to/jacktt/creating-dynamic-readmemd-file-388o) - 09/09/2023 +- [Search Goole Like a Pro [Cheat sheet]](https://dev.to/jacktt/search-goole-like-a-pro-cheat-sheet-555g) - 30/08/2023 +- [Advanced Go Build Techniques](https://dev.to/jacktt/go-build-in-advance-4o8n) - 30/08/2023 +- [Load Private Module in Golang Project](https://dev.to/jacktt/load-private-module-in-golang-project-122l) - 12/08/2023 +- [Speed up your query in Postgres](https://dev.to/jacktt/speed-up-your-query-in-postgres-48e3) - 23/06/2023 + +*Updated at: 2023-09-13T19:51:42+07:00* \ No newline at end of file diff --git a/README.md.template b/README.md.template new file mode 100644 index 0000000..82f79ed --- /dev/null +++ b/README.md.template @@ -0,0 +1,58 @@ +## About +Collect your latest articles from sources such as [dev.to](https://dev.to), and then update the `README.md`. + +## Use GitHub Action to update your README + +**Step 1:** In your repository, create a file named `README.md.template`. + +**Step 2:** Write anything you want within the `README.md.template` file. + +**Step 3:** Embed one of the following entities within your `README.md.template`: + +- **Article listing:** +```shell +{{`{{`}} template "article-list" .Articles {{`}}`}} +``` + +If you are familiar with Go templates, you have access to the `root` variable, which includes the following fields: + +- `Articles`: An array of Article. You can view the Article struct definition in [model/article.go](model/article.go). +- `Time`: Updated Time + +**Step 4**: Register Github Action +- Create a file `.github/workflows/update-articles.yml` in your repository. +```yml +name: "Cronjob" +on: +schedule: +- cron: '15 0 * * *' + +jobs: + update-articles: + permissions: write-all + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Generate README + uses: huantt/article-listing@v1.0.0 + with: + username: YOUR_USERNAME_ON_DEV_TO + template-file: 'README.md.template' + out-file: 'README.md' + limit: 5 + - name: Commit + run: | + git config user.name github-actions + git config user.email github-actions@github.com + git add . + git commit -m "update articles" + git push origin main +``` + +**Step 5**: Commit your change, then Github actions will run as your specified cron to update Articles into your README.md file + +## Below is my recent articles {{ .Author }} collected from dev.to + +{{ template "article-list" .Articles }} + +*Updated at: {{ formatTime .Time }}* \ No newline at end of file diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..8299ee9 --- /dev/null +++ b/action.yml @@ -0,0 +1,24 @@ +name: 'Article updater' +description: 'Collect articles then lists to README.md' +inputs: + username: + description: 'Username' + required: true + template-file: + description: 'Template file path' + required: true + out-file: + description: 'Output file path' + required: true + limit: + description: 'Limit number of articles' + required: false + default: '5' +runs: + using: 'docker' + image: 'Dockerfile' + args: + - --limit=${{ inputs.limit }} + - --username=${{ inputs.username }} + - --template-file=${{ inputs.template-file }} + - --out-file=${{ inputs.out-file }} diff --git a/cmd/weather.go b/cmd/weather.go new file mode 100644 index 0000000..4475d67 --- /dev/null +++ b/cmd/weather.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "context" + "github.com/huantt/article-listing/handler/collector" + "github.com/huantt/article-listing/impl/article_service/forem" + foremsrv "github.com/huantt/article-listing/pkg/forem" + "github.com/spf13/cobra" + "log/slog" + "os" +) + +func UpdateWeather(use string) *cobra.Command { + var templateFilePath string + var outputFilePath string + var username string + var limit int + + command := &cobra.Command{ + Use: use, + Run: func(cmd *cobra.Command, args []string) { + devToService := forem.NewService(foremsrv.NewService(foremsrv.DevToEndpoint)) + handler := collector.NewHandler(devToService) + err := handler.Collect(context.Background(), username, limit, templateFilePath, outputFilePath) + if err != nil { + slog.Error(err.Error()) + os.Exit(1) + } + slog.Info("Updated!") + }, + } + + command.Flags().StringVarP(&templateFilePath, "template-file", "f", "", "Readme template file path") + command.Flags().StringVarP(&outputFilePath, "out-file", "o", "", "Output file path") + command.Flags().StringVarP(&username, "username", "u", "", "Username") + command.Flags().IntVar(&limit, "limit", 5, "Limit number of articles") + err := command.MarkFlagRequired("username") + if err != nil { + slog.Error(err.Error()) + os.Exit(1) + } + err = command.MarkFlagRequired("template-file") + if err != nil { + slog.Error(err.Error()) + os.Exit(1) + } + err = command.MarkFlagRequired("out-file") + if err != nil { + slog.Error(err.Error()) + os.Exit(1) + } + + return command +} diff --git a/data/template.md.tpl b/data/template.md.tpl new file mode 100644 index 0000000..7644fcf --- /dev/null +++ b/data/template.md.tpl @@ -0,0 +1,3 @@ +## Top articles + +{{ template "article-list" $.Articles }} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c046c06 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/huantt/article-listing + +go 1.21 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-resty/resty/v2 v2.7.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/cobra v1.7.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect + golang.org/x/net v0.0.0-20211029224645-99673261e6eb // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..703eb28 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= +github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.0.0-20211029224645-99673261e6eb h1:pirldcYWx7rx7kE5r+9WsOXPXK0+WH5+uZ7uPmJ44uM= +golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler/collector/handler.go b/handler/collector/handler.go new file mode 100644 index 0000000..0b3f1eb --- /dev/null +++ b/handler/collector/handler.go @@ -0,0 +1,76 @@ +package collector + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/huantt/article-listing/model" + "html/template" + "log/slog" + "os" + "time" +) + +type Handler struct { + articleService ArticleService +} + +func NewHandler(articleService ArticleService) *Handler { + return &Handler{articleService} +} + +func (h *Handler) Collect(ctx context.Context, username string, limit int, templateFilePath, outputFilePath string) error { + articles, err := h.articleService.GetArticles(ctx, username, 1, limit) + if err != nil { + return err + } + slog.Info(fmt.Sprintf("Got %d article from %s", len(articles), username)) + + templateStr, err := os.ReadFile(templateFilePath) + if err != nil { + return err + } + output, err := generateOutput(articles, string(templateStr), templates...) + if err != nil { + return err + } + return os.WriteFile(outputFilePath, []byte(*output), 0644) +} + +func generateOutput(articles []model.Article, readmeTemplate string, templates ...string) (*string, error) { + if len(articles) == 0 { + return nil, errors.New("articles must be not empty") + } + tmpl, err := template. + New("article"). + Funcs(template.FuncMap{ + "formatDate": formatDate, + "formatHour": formatHour, + "formatTime": formatTime, + "truncateByWords": truncateByWords, + }). + Parse(readmeTemplate) + if err != nil { + return nil, err + } + + for _, t := range templates { + tmpl, err = tmpl.Parse(t) + if err != nil { + return nil, err + } + } + + var result bytes.Buffer + err = tmpl.ExecuteTemplate(&result, "article", map[string]any{ + "Articles": articles, + "Author": articles[0].Author, + "Time": time.Now(), + }) + if err != nil { + return nil, err + } + stringResult := result.String() + return &stringResult, nil +} diff --git a/handler/collector/handler_test.go b/handler/collector/handler_test.go new file mode 100644 index 0000000..bb92f2d --- /dev/null +++ b/handler/collector/handler_test.go @@ -0,0 +1,30 @@ +package collector + +import ( + _ "embed" + "encoding/json" + "github.com/huantt/article-listing/model" + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +//go:embed testdata/articles.json +var articleData []byte + +//go:embed testdata/template.md.tpl +var templateData string + +func TestGenerateOutput(t *testing.T) { + var articles []model.Article + err := json.Unmarshal(articleData, &articles) + if err != nil { + panic(err) + } + + output, err := generateOutput(articles, templateData, templates...) + assert.NoError(t, err) + assert.NotNil(t, output) + assert.NotEmpty(t, *output) + _ = os.WriteFile("../../.test/README.md", []byte(*output), 0644) +} diff --git a/handler/collector/interface.go b/handler/collector/interface.go new file mode 100644 index 0000000..6d298d9 --- /dev/null +++ b/handler/collector/interface.go @@ -0,0 +1,10 @@ +package collector + +import ( + "context" + "github.com/huantt/article-listing/model" +) + +type ArticleService interface { + GetArticles(ctx context.Context, username string, page, perPage int) ([]model.Article, error) +} diff --git a/handler/collector/templates.go b/handler/collector/templates.go new file mode 100644 index 0000000..c2060f5 --- /dev/null +++ b/handler/collector/templates.go @@ -0,0 +1,10 @@ +package collector + +import _ "embed" + +//go:embed templates/list.tpl +var articleListTemplate string + +var templates = []string{ + articleListTemplate, +} diff --git a/handler/collector/templates/list.tpl b/handler/collector/templates/list.tpl new file mode 100644 index 0000000..daa7306 --- /dev/null +++ b/handler/collector/templates/list.tpl @@ -0,0 +1,5 @@ +{{- define "article-list"}} + {{- range $i, $article := .}} +- [{{ truncateByWords $article.Title 10 }}]({{ $article.Url }}) - {{ formatDate $article.CreatedAt }} + {{- end}} +{{- end}} \ No newline at end of file diff --git a/handler/collector/testdata/articles.json b/handler/collector/testdata/articles.json new file mode 100644 index 0000000..d3e0aea --- /dev/null +++ b/handler/collector/testdata/articles.json @@ -0,0 +1,159 @@ +[ + { + "url": "https://dev.to/jacktt/creating-dynamic-readmemd-file-388o", + "title": "Creating Dynamic README.md File", + "description": "This is my Github Profile. The specific thing here is that the weather is updated every 6 hours...", + "thumbnail": "https://res.cloudinary.com/practicaldev/image/fetch/s--9aLNv3pz--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/urlpgle748e1db4sw81v.png", + "author": "Jack", + "created_at": "2023-09-09T13:09:24Z", + "updated_at": null, + "tags": [ + "git", + "cicd", + "devops", + "go" + ] + }, + { + "url": "https://dev.to/jacktt/search-goole-like-a-pro-cheat-sheet-555g", + "title": "Search Goole Like a Pro [Cheat sheet]", + "description": "Before reading my article, let's try searching the following input: inurl:/jacktt/ site:dev.to ...", + "thumbnail": "", + "author": "Jack", + "created_at": "2023-08-30T10:49:04Z", + "updated_at": null, + "tags": [ + "tutorial", + "learning", + "google" + ] + }, + { + "url": "https://dev.to/jacktt/go-build-in-advance-4o8n", + "title": "Advanced Go Build Techniques", + "description": "Table of contents Build options Which file will be included Build tags Build contraints ...", + "thumbnail": "", + "author": "Jack", + "created_at": "2023-08-30T06:25:09Z", + "updated_at": null, + "tags": [ + "go", + "development", + "devops", + "backend" + ] + }, + { + "url": "https://dev.to/jacktt/load-private-module-in-golang-project-122l", + "title": "Load Private Module in Golang Project", + "description": "Table of Contents I. How Does go get Work? II. How to Load Private Modules III. Build...", + "thumbnail": "https://res.cloudinary.com/practicaldev/image/fetch/s--ZviKv8F5--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b5m3kjdhd57zgk0xpcdr.png", + "author": "Jack", + "created_at": "2023-08-12T09:12:22Z", + "updated_at": null, + "tags": [ + "go", + "devops", + "backend" + ] + }, + { + "url": "https://dev.to/jacktt/speed-up-your-query-in-postgres-48e3", + "title": "Speed up your query in Postgres", + "description": "Table of contents Indexing Use EXPLAIN ANALYZE Use UNION Instead of OR to Use Index Use...", + "thumbnail": "", + "author": "Jack", + "created_at": "2023-06-23T02:01:05Z", + "updated_at": null, + "tags": [ + "database" + ] + }, + { + "url": "https://dev.to/jacktt/kafka-dump-backup-restore-stream-1fee", + "title": "Kafka dump: backup, restore, stream,...", + "description": "Source code Kafka dump Kafka data backup Kafka dump is a tool to back up and...", + "thumbnail": "", + "author": "Jack", + "created_at": "2023-06-28T01:46:20Z", + "updated_at": null, + "tags": [ + "go", + "opensource" + ] + }, + { + "url": "https://dev.to/jacktt/implement-rate-limit-in-golang-l4g", + "title": "Implement rate limit in Golang", + "description": "This is a short code that demo how to implement rate limit in Golang. package main import ( ...", + "thumbnail": "https://res.cloudinary.com/practicaldev/image/fetch/s--ET8llk7K--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b1valhxbvuljnyazbgx1.jpg", + "author": "Jack", + "created_at": "2023-06-23T10:41:23Z", + "updated_at": null, + "tags": [ + "go" + ] + }, + { + "url": "https://dev.to/jacktt/exploring-some-powerful-features-of-golang-5f71", + "title": "Exploring some Powerful Features of Golang", + "description": "Table of content Goroutines Channel Buffered Channel Defer Select Plugin Binary...", + "thumbnail": "https://res.cloudinary.com/practicaldev/image/fetch/s--ZyeXTtkm--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bn7oslniwaecdfif2vqc.png", + "author": "Jack", + "created_at": "2023-06-22T01:34:27Z", + "updated_at": null, + "tags": [ + "go" + ] + }, + { + "url": "https://dev.to/jacktt/multiple-git-configs-profiles-on-one-computer-13l2", + "title": "Multiple git configs (profiles) on one computer", + "description": "How to let git know which profile should be used in specific folders? Imagine that you’re...", + "thumbnail": "", + "author": "Jack", + "created_at": "2023-06-21T06:41:16Z", + "updated_at": null, + "tags": [ + "git" + ] + }, + { + "url": "https://dev.to/jacktt/solidity-reentrancy-vulnerability-13o9", + "title": "[Solidity] Reentrancy vulnerability", + "description": "What is Reentrancy vulnerability Reentrancy vulnerability is a type of vulnerability that...", + "thumbnail": "", + "author": "Jack", + "created_at": "2023-06-21T06:35:58Z", + "updated_at": null, + "tags": [ + "solidity", + "blockchain", + "security" + ] + }, + { + "url": "https://dev.to/jacktt/decentralized-exchange-limit-order-why-not-24m3", + "title": "Decentralized Exchange limit-order — Why not?", + "description": "What is limit-order feature? Imagine you’re trading Token A at a price of $0.1, and your...", + "thumbnail": "https://res.cloudinary.com/practicaldev/image/fetch/s--YKAQN_PN--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ysxj2be4l3i2dlss1mrk.png", + "author": "Jack", + "created_at": "2023-06-21T06:31:49Z", + "updated_at": null, + "tags": [ + "blockchain" + ] + }, + { + "url": "https://dev.to/jacktt/plugin-in-golang-4m67", + "title": "Plugin in Golang", + "description": "Golang plugin implementation This repository explores the plugin feature introduced in Go...", + "thumbnail": "https://res.cloudinary.com/practicaldev/image/fetch/s--LGq4pYkq--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3tnmfs7et7jy7l007xd8.png", + "author": "Jack", + "created_at": "2023-06-21T06:29:45Z", + "updated_at": null, + "tags": [ + "go" + ] + } +] diff --git a/handler/collector/testdata/template.md.tpl b/handler/collector/testdata/template.md.tpl new file mode 100644 index 0000000..27a4898 --- /dev/null +++ b/handler/collector/testdata/template.md.tpl @@ -0,0 +1,3 @@ +## Top articles of {{ .Author }} + +{{ template "article-list" .Articles }} \ No newline at end of file diff --git a/handler/collector/tmpl_funcs.go b/handler/collector/tmpl_funcs.go new file mode 100644 index 0000000..41dd2ad --- /dev/null +++ b/handler/collector/tmpl_funcs.go @@ -0,0 +1,53 @@ +package collector + +import ( + "time" + "unicode" + "unicode/utf8" +) + +func formatDate(date time.Time) string { + return date.Format("02/01/2006") +} + +func formatHour(date time.Time) string { + return date.Format("15:04") +} + +func formatTime(date time.Time) string { + return date.Format(time.RFC3339) +} + +func truncateByWords(input string, maxWords int) string { + processedWords := 0 + wordStarted := false + for i := 0; i < len(input); { + r, width := utf8.DecodeRuneInString(input[i:]) + if !unicode.IsSpace(r) { + i += width + wordStarted = true + continue + } + + if !wordStarted { + i += width + continue + } + + wordStarted = false + processedWords++ + if processedWords == maxWords { + const ending = "..." + if (i + len(ending)) >= len(input) { + // Source string ending is shorter than "..." + return input + } + + return input[:i] + ending + } + + i += width + } + + return input +} diff --git a/handler/collector/tmpl_funcs_test.go b/handler/collector/tmpl_funcs_test.go new file mode 100644 index 0000000..b946924 --- /dev/null +++ b/handler/collector/tmpl_funcs_test.go @@ -0,0 +1,22 @@ +package collector + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestTruncate(t *testing.T) { + tests := []struct { + input string + limitWords int + expectedOutput string + }{ + {"Im Jack from Vietnam", 3, "Im Jack from..."}, + {"Im Jack from Vietnam", 4, "Im Jack from Vietnam"}, + {"Im Jack from Vietnam", 5, "Im Jack from Vietnam"}, + } + for _, test := range tests { + output := truncateByWords(test.input, test.limitWords) + assert.Equal(t, test.expectedOutput, output) + } +} diff --git a/impl/article_service/forem/service.go b/impl/article_service/forem/service.go new file mode 100644 index 0000000..b92b380 --- /dev/null +++ b/impl/article_service/forem/service.go @@ -0,0 +1,48 @@ +package forem + +import ( + "context" + "github.com/huantt/article-listing/model" + "github.com/huantt/article-listing/pkg/forem" +) + +type Service struct { + foremService *forem.Service +} + +func NewService(foremService *forem.Service) *Service { + return &Service{foremService: foremService} +} + +func (s *Service) GetArticles(ctx context.Context, username string, page, perPage int) ([]model.Article, error) { + articles, err := s.foremService.GetArticles(ctx, forem.GetArticlesPrams{ + UserName: username, + Page: page, + PerPage: perPage, + }) + if err != nil { + return nil, err + } + + return toModels(articles), nil +} +func toModels(articles []forem.Article) []model.Article { + var result []model.Article + for _, article := range articles { + result = append(result, toModel(article)) + } + return result +} + +func toModel(article forem.Article) model.Article { + return model.Article{ + Url: article.Url, + Title: article.Title, + Description: article.Description, + Thumbnail: article.CoverImage, + Author: article.User.Name, + CreatedAt: article.CreatedAt, + UpdatedAt: article.EditedAt, + Tags: article.TagList, + } +} diff --git a/impl/article_service/forem/service_test.go b/impl/article_service/forem/service_test.go new file mode 100644 index 0000000..cc7943c --- /dev/null +++ b/impl/article_service/forem/service_test.go @@ -0,0 +1,23 @@ +package forem + +import ( + _ "embed" + "encoding/json" + "github.com/huantt/article-listing/pkg/forem" + "github.com/stretchr/testify/assert" + "testing" +) + +//go:embed testdata/articles.json +var articleData []byte + +func TestToModels(t *testing.T) { + var articles []forem.Article + err := json.Unmarshal(articleData, &articles) + if err != nil { + panic(err) + } + + models := toModels(articles) + assert.Equal(t, len(articles), len(models)) +} diff --git a/impl/article_service/forem/testdata/articles.json b/impl/article_service/forem/testdata/articles.json new file mode 100644 index 0000000..69ae9c8 --- /dev/null +++ b/impl/article_service/forem/testdata/articles.json @@ -0,0 +1,471 @@ +[ + { + "type_of": "article", + "id": 1594564, + "title": "Creating Dynamic README.md File", + "description": "This is my Github Profile. The specific thing here is that the weather is updated every 6 hours...", + "readable_publish_date": "Sep 10", + "slug": "creating-dynamic-readmemd-file-388o", + "path": "/jacktt/creating-dynamic-readmemd-file-388o", + "url": "https://dev.to/jacktt/creating-dynamic-readmemd-file-388o", + "comments_count": 13, + "public_reactions_count": 73, + "collection_id": null, + "published_timestamp": "2023-09-10T07:58:12Z", + "positive_reactions_count": 73, + "cover_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--9aLNv3pz--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/urlpgle748e1db4sw81v.png", + "social_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--P9UKOtSq--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/urlpgle748e1db4sw81v.png", + "canonical_url": "https://dev.to/jacktt/creating-dynamic-readmemd-file-388o", + "created_at": "2023-09-09T13:09:24Z", + "edited_at": "2023-09-12T06:17:33Z", + "crossposted_at": null, + "published_at": "2023-09-10T07:58:12Z", + "last_comment_at": "2023-09-13T03:49:48Z", + "reading_time_minutes": 3, + "tag_list": [ + "git", + "cicd", + "devops", + "go" + ], + "tags": "git, cicd, devops, go", + "user": { + "name": "Jack", + "username": "jacktt", + "twitter_username": "_jacktt", + "github_username": "huantt", + "user_id": 1105551, + "website_url": "https://github.com/huantt", + "profile_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--tpUJfSYb--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg", + "profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--Fb5bFY70--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg" + } + }, + { + "type_of": "article", + "id": 1584387, + "title": "Search Goole Like a Pro [Cheat sheet]", + "description": "Before reading my article, let's try searching the following input: inurl:/jacktt/ site:dev.to ...", + "readable_publish_date": "Aug 30", + "slug": "search-goole-like-a-pro-cheat-sheet-555g", + "path": "/jacktt/search-goole-like-a-pro-cheat-sheet-555g", + "url": "https://dev.to/jacktt/search-goole-like-a-pro-cheat-sheet-555g", + "comments_count": 0, + "public_reactions_count": 4, + "collection_id": null, + "published_timestamp": "2023-08-30T10:50:13Z", + "positive_reactions_count": 4, + "cover_image": null, + "social_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--xK6wXD1L--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o1tlndtr6jhfa9veuysc.png", + "canonical_url": "https://dev.to/jacktt/search-goole-like-a-pro-cheat-sheet-555g", + "created_at": "2023-08-30T10:49:04Z", + "edited_at": "2023-08-31T04:48:41Z", + "crossposted_at": null, + "published_at": "2023-08-30T10:50:13Z", + "last_comment_at": "2023-08-30T10:50:13Z", + "reading_time_minutes": 2, + "tag_list": [ + "tutorial", + "learning", + "google" + ], + "tags": "tutorial, learning, google", + "user": { + "name": "Jack", + "username": "jacktt", + "twitter_username": "_jacktt", + "github_username": "huantt", + "user_id": 1105551, + "website_url": "https://github.com/huantt", + "profile_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--tpUJfSYb--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg", + "profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--Fb5bFY70--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg" + } + }, + { + "type_of": "article", + "id": 1584151, + "title": "Advanced Go Build Techniques", + "description": "Table of contents Build options Which file will be included Build tags Build contraints ...", + "readable_publish_date": "Aug 30", + "slug": "go-build-in-advance-4o8n", + "path": "/jacktt/go-build-in-advance-4o8n", + "url": "https://dev.to/jacktt/go-build-in-advance-4o8n", + "comments_count": 2, + "public_reactions_count": 13, + "collection_id": null, + "published_timestamp": "2023-08-30T06:31:34Z", + "positive_reactions_count": 13, + "cover_image": null, + "social_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--5MoFjxsB--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zlbrjsxcyxsdc9bqcllf.png", + "canonical_url": "https://dev.to/jacktt/go-build-in-advance-4o8n", + "created_at": "2023-08-30T06:25:09Z", + "edited_at": "2023-09-02T14:58:15Z", + "crossposted_at": null, + "published_at": "2023-08-30T06:31:34Z", + "last_comment_at": "2023-09-04T00:01:49Z", + "reading_time_minutes": 4, + "tag_list": [ + "go", + "development", + "devops", + "backend" + ], + "tags": "go, development, devops, backend", + "user": { + "name": "Jack", + "username": "jacktt", + "twitter_username": "_jacktt", + "github_username": "huantt", + "user_id": 1105551, + "website_url": "https://github.com/huantt", + "profile_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--tpUJfSYb--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg", + "profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--Fb5bFY70--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg" + } + }, + { + "type_of": "article", + "id": 1566499, + "title": "Load Private Module in Golang Project", + "description": "Table of Contents I. How Does go get Work? II. How to Load Private Modules III. Build...", + "readable_publish_date": "Aug 12", + "slug": "load-private-module-in-golang-project-122l", + "path": "/jacktt/load-private-module-in-golang-project-122l", + "url": "https://dev.to/jacktt/load-private-module-in-golang-project-122l", + "comments_count": 0, + "public_reactions_count": 5, + "collection_id": null, + "published_timestamp": "2023-08-12T10:05:22Z", + "positive_reactions_count": 5, + "cover_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--ZviKv8F5--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b5m3kjdhd57zgk0xpcdr.png", + "social_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--SLJ8MBdt--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b5m3kjdhd57zgk0xpcdr.png", + "canonical_url": "https://dev.to/jacktt/load-private-module-in-golang-project-122l", + "created_at": "2023-08-12T09:12:22Z", + "edited_at": "2023-08-29T05:41:35Z", + "crossposted_at": null, + "published_at": "2023-08-12T10:05:22Z", + "last_comment_at": "2023-08-12T10:05:22Z", + "reading_time_minutes": 4, + "tag_list": [ + "go", + "devops", + "backend" + ], + "tags": "go, devops, backend", + "user": { + "name": "Jack", + "username": "jacktt", + "twitter_username": "_jacktt", + "github_username": "huantt", + "user_id": 1105551, + "website_url": "https://github.com/huantt", + "profile_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--tpUJfSYb--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg", + "profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--Fb5bFY70--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg" + } + }, + { + "type_of": "article", + "id": 1513887, + "title": "Speed up your query in Postgres", + "description": "Table of contents Indexing Use EXPLAIN ANALYZE Use UNION Instead of OR to Use Index Use...", + "readable_publish_date": "Jul 3", + "slug": "speed-up-your-query-in-postgres-48e3", + "path": "/jacktt/speed-up-your-query-in-postgres-48e3", + "url": "https://dev.to/jacktt/speed-up-your-query-in-postgres-48e3", + "comments_count": 0, + "public_reactions_count": 0, + "collection_id": null, + "published_timestamp": "2023-07-03T06:22:17Z", + "positive_reactions_count": 0, + "cover_image": null, + "social_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--xDIvM6Ob--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5uhzgd4eulybklu992s3.png", + "canonical_url": "https://dev.to/jacktt/speed-up-your-query-in-postgres-48e3", + "created_at": "2023-06-23T02:01:05Z", + "edited_at": "2023-07-09T14:00:26Z", + "crossposted_at": null, + "published_at": "2023-07-03T06:22:17Z", + "last_comment_at": "2023-07-03T06:22:17Z", + "reading_time_minutes": 5, + "tag_list": [ + "database" + ], + "tags": "database", + "user": { + "name": "Jack", + "username": "jacktt", + "twitter_username": "_jacktt", + "github_username": "huantt", + "user_id": 1105551, + "website_url": "https://github.com/huantt", + "profile_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--tpUJfSYb--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg", + "profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--Fb5bFY70--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg" + } + }, + { + "type_of": "article", + "id": 1519116, + "title": "Kafka dump: backup, restore, stream,...", + "description": "Source code Kafka dump Kafka data backup Kafka dump is a tool to back up and...", + "readable_publish_date": "Jun 28", + "slug": "kafka-dump-backup-restore-stream-1fee", + "path": "/jacktt/kafka-dump-backup-restore-stream-1fee", + "url": "https://dev.to/jacktt/kafka-dump-backup-restore-stream-1fee", + "comments_count": 0, + "public_reactions_count": 0, + "collection_id": null, + "published_timestamp": "2023-06-28T01:46:19Z", + "positive_reactions_count": 0, + "cover_image": null, + "social_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--5lrnNV72--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6es7qeviro3o990sreay.png", + "canonical_url": "https://dev.to/jacktt/kafka-dump-backup-restore-stream-1fee", + "created_at": "2023-06-28T01:46:20Z", + "edited_at": null, + "crossposted_at": null, + "published_at": "2023-06-28T01:46:19Z", + "last_comment_at": "2023-06-28T01:46:19Z", + "reading_time_minutes": 3, + "tag_list": [ + "go", + "opensource" + ], + "tags": "go, opensource", + "user": { + "name": "Jack", + "username": "jacktt", + "twitter_username": "_jacktt", + "github_username": "huantt", + "user_id": 1105551, + "website_url": "https://github.com/huantt", + "profile_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--tpUJfSYb--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg", + "profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--Fb5bFY70--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg" + } + }, + { + "type_of": "article", + "id": 1514327, + "title": "Implement rate limit in Golang", + "description": "This is a short code that demo how to implement rate limit in Golang. package main import ( ...", + "readable_publish_date": "Jun 28", + "slug": "implement-rate-limit-in-golang-l4g", + "path": "/jacktt/implement-rate-limit-in-golang-l4g", + "url": "https://dev.to/jacktt/implement-rate-limit-in-golang-l4g", + "comments_count": 2, + "public_reactions_count": 1, + "collection_id": null, + "published_timestamp": "2023-06-28T01:32:35Z", + "positive_reactions_count": 1, + "cover_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--ET8llk7K--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b1valhxbvuljnyazbgx1.jpg", + "social_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--7B_O6_jM--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b1valhxbvuljnyazbgx1.jpg", + "canonical_url": "https://dev.to/jacktt/implement-rate-limit-in-golang-l4g", + "created_at": "2023-06-23T10:41:23Z", + "edited_at": "2023-07-03T02:11:48Z", + "crossposted_at": null, + "published_at": "2023-06-28T01:32:35Z", + "last_comment_at": "2023-07-03T05:51:40Z", + "reading_time_minutes": 2, + "tag_list": [ + "go" + ], + "tags": "go", + "user": { + "name": "Jack", + "username": "jacktt", + "twitter_username": "_jacktt", + "github_username": "huantt", + "user_id": 1105551, + "website_url": "https://github.com/huantt", + "profile_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--tpUJfSYb--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg", + "profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--Fb5bFY70--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg" + } + }, + { + "type_of": "article", + "id": 1512634, + "title": "Exploring some Powerful Features of Golang", + "description": "Table of content Goroutines Channel Buffered Channel Defer Select Plugin Binary...", + "readable_publish_date": "Jun 22", + "slug": "exploring-some-powerful-features-of-golang-5f71", + "path": "/jacktt/exploring-some-powerful-features-of-golang-5f71", + "url": "https://dev.to/jacktt/exploring-some-powerful-features-of-golang-5f71", + "comments_count": 0, + "public_reactions_count": 2, + "collection_id": null, + "published_timestamp": "2023-06-22T01:34:26Z", + "positive_reactions_count": 2, + "cover_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--ZyeXTtkm--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bn7oslniwaecdfif2vqc.png", + "social_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--WzO2P102--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bn7oslniwaecdfif2vqc.png", + "canonical_url": "https://dev.to/jacktt/exploring-some-powerful-features-of-golang-5f71", + "created_at": "2023-06-22T01:34:27Z", + "edited_at": "2023-06-22T01:42:11Z", + "crossposted_at": null, + "published_at": "2023-06-22T01:34:26Z", + "last_comment_at": "2023-06-22T01:34:26Z", + "reading_time_minutes": 3, + "tag_list": [ + "go" + ], + "tags": "go", + "user": { + "name": "Jack", + "username": "jacktt", + "twitter_username": "_jacktt", + "github_username": "huantt", + "user_id": 1105551, + "website_url": "https://github.com/huantt", + "profile_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--tpUJfSYb--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg", + "profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--Fb5bFY70--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg" + } + }, + { + "type_of": "article", + "id": 1511556, + "title": "Multiple git configs (profiles) on one computer", + "description": "How to let git know which profile should be used in specific folders? Imagine that you’re...", + "readable_publish_date": "Jun 21", + "slug": "multiple-git-configs-profiles-on-one-computer-13l2", + "path": "/jacktt/multiple-git-configs-profiles-on-one-computer-13l2", + "url": "https://dev.to/jacktt/multiple-git-configs-profiles-on-one-computer-13l2", + "comments_count": 2, + "public_reactions_count": 6, + "collection_id": null, + "published_timestamp": "2023-06-21T06:41:16Z", + "positive_reactions_count": 6, + "cover_image": null, + "social_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--i-2_7AsQ--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d6l05ej5aw35538d9f2q.png", + "canonical_url": "https://dev.to/jacktt/multiple-git-configs-profiles-on-one-computer-13l2", + "created_at": "2023-06-21T06:41:16Z", + "edited_at": null, + "crossposted_at": null, + "published_at": "2023-06-21T06:41:16Z", + "last_comment_at": "2023-08-15T01:46:09Z", + "reading_time_minutes": 1, + "tag_list": [ + "git" + ], + "tags": "git", + "user": { + "name": "Jack", + "username": "jacktt", + "twitter_username": "_jacktt", + "github_username": "huantt", + "user_id": 1105551, + "website_url": "https://github.com/huantt", + "profile_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--tpUJfSYb--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg", + "profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--Fb5bFY70--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg" + } + }, + { + "type_of": "article", + "id": 1511551, + "title": "[Solidity] Reentrancy vulnerability", + "description": "What is Reentrancy vulnerability Reentrancy vulnerability is a type of vulnerability that...", + "readable_publish_date": "Jun 21", + "slug": "solidity-reentrancy-vulnerability-13o9", + "path": "/jacktt/solidity-reentrancy-vulnerability-13o9", + "url": "https://dev.to/jacktt/solidity-reentrancy-vulnerability-13o9", + "comments_count": 0, + "public_reactions_count": 0, + "collection_id": null, + "published_timestamp": "2023-06-21T06:35:57Z", + "positive_reactions_count": 0, + "cover_image": null, + "social_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--r8Dg6E-e--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/un69j1guoxsoqqe5yrep.png", + "canonical_url": "https://dev.to/jacktt/solidity-reentrancy-vulnerability-13o9", + "created_at": "2023-06-21T06:35:58Z", + "edited_at": null, + "crossposted_at": null, + "published_at": "2023-06-21T06:35:57Z", + "last_comment_at": "2023-06-21T06:35:57Z", + "reading_time_minutes": 2, + "tag_list": [ + "solidity", + "blockchain", + "security" + ], + "tags": "solidity, blockchain, security", + "user": { + "name": "Jack", + "username": "jacktt", + "twitter_username": "_jacktt", + "github_username": "huantt", + "user_id": 1105551, + "website_url": "https://github.com/huantt", + "profile_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--tpUJfSYb--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg", + "profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--Fb5bFY70--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg" + } + }, + { + "type_of": "article", + "id": 1511549, + "title": "Decentralized Exchange limit-order — Why not?", + "description": "What is limit-order feature? Imagine you’re trading Token A at a price of $0.1, and your...", + "readable_publish_date": "Jun 21", + "slug": "decentralized-exchange-limit-order-why-not-24m3", + "path": "/jacktt/decentralized-exchange-limit-order-why-not-24m3", + "url": "https://dev.to/jacktt/decentralized-exchange-limit-order-why-not-24m3", + "comments_count": 0, + "public_reactions_count": 0, + "collection_id": null, + "published_timestamp": "2023-06-21T06:31:48Z", + "positive_reactions_count": 0, + "cover_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--YKAQN_PN--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ysxj2be4l3i2dlss1mrk.png", + "social_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--Co1C6LVr--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ysxj2be4l3i2dlss1mrk.png", + "canonical_url": "https://dev.to/jacktt/decentralized-exchange-limit-order-why-not-24m3", + "created_at": "2023-06-21T06:31:49Z", + "edited_at": "2023-06-21T06:32:27Z", + "crossposted_at": null, + "published_at": "2023-06-21T06:31:48Z", + "last_comment_at": "2023-06-21T06:31:48Z", + "reading_time_minutes": 2, + "tag_list": [ + "blockchain" + ], + "tags": "blockchain", + "user": { + "name": "Jack", + "username": "jacktt", + "twitter_username": "_jacktt", + "github_username": "huantt", + "user_id": 1105551, + "website_url": "https://github.com/huantt", + "profile_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--tpUJfSYb--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg", + "profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--Fb5bFY70--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg" + } + }, + { + "type_of": "article", + "id": 1511547, + "title": "Plugin in Golang", + "description": "Golang plugin implementation This repository explores the plugin feature introduced in Go...", + "readable_publish_date": "Jun 21", + "slug": "plugin-in-golang-4m67", + "path": "/jacktt/plugin-in-golang-4m67", + "url": "https://dev.to/jacktt/plugin-in-golang-4m67", + "comments_count": 2, + "public_reactions_count": 13, + "collection_id": null, + "published_timestamp": "2023-06-21T06:29:45Z", + "positive_reactions_count": 13, + "cover_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--LGq4pYkq--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3tnmfs7et7jy7l007xd8.png", + "social_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--mQ6qF9-z--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3tnmfs7et7jy7l007xd8.png", + "canonical_url": "https://dev.to/jacktt/plugin-in-golang-4m67", + "created_at": "2023-06-21T06:29:45Z", + "edited_at": null, + "crossposted_at": null, + "published_at": "2023-06-21T06:29:45Z", + "last_comment_at": "2023-08-12T10:12:02Z", + "reading_time_minutes": 2, + "tag_list": [ + "go" + ], + "tags": "go", + "user": { + "name": "Jack", + "username": "jacktt", + "twitter_username": "_jacktt", + "github_username": "huantt", + "user_id": 1105551, + "website_url": "https://github.com/huantt", + "profile_image": "https://res.cloudinary.com/practicaldev/image/fetch/s--tpUJfSYb--/c_fill,f_auto,fl_progressive,h_640,q_auto,w_640/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg", + "profile_image_90": "https://res.cloudinary.com/practicaldev/image/fetch/s--Fb5bFY70--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/1105551/7d1507de-34a6-43fe-be56-324409393281.jpeg" + } + } +] diff --git a/main.go b/main.go new file mode 100644 index 0000000..0a162af --- /dev/null +++ b/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "github.com/huantt/article-listing/cmd" + "github.com/spf13/cobra" + "log/slog" + "os" +) + +func main() { + var loggingLevel = new(slog.LevelVar) + loggingLevel.Set(slog.LevelInfo) + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: loggingLevel})) + slog.SetDefault(logger) + + root := &cobra.Command{} + root.AddCommand(cmd.UpdateWeather("update-articles")) + err := root.Execute() + if err != nil { + slog.Error(err.Error()) + os.Exit(1) + } +} diff --git a/model/article.go b/model/article.go new file mode 100644 index 0000000..4215412 --- /dev/null +++ b/model/article.go @@ -0,0 +1,14 @@ +package model + +import "time" + +type Article struct { + Url string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + Thumbnail string `json:"thumbnail"` + Author string `json:"author"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + Tags []string `json:"tags"` +} diff --git a/pkg/forem/constant.go b/pkg/forem/constant.go new file mode 100644 index 0000000..424cfa8 --- /dev/null +++ b/pkg/forem/constant.go @@ -0,0 +1,5 @@ +package forem + +const ( + DevToEndpoint = "https://dev.to" +) diff --git a/pkg/forem/model.go b/pkg/forem/model.go new file mode 100644 index 0000000..6266365 --- /dev/null +++ b/pkg/forem/model.go @@ -0,0 +1,55 @@ +package forem + +import ( + "time" +) + +type Article struct { + TypeOf string `json:"type_of"` + Id int `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + ReadablePublishDate string `json:"readable_publish_date"` + Slug string `json:"slug"` + Path string `json:"path"` + Url string `json:"url"` + CommentsCount int `json:"comments_count"` + PublicReactionsCount int `json:"public_reactions_count"` + PublishedTimestamp *time.Time `json:"published_timestamp"` + PositiveReactionsCount int `json:"positive_reactions_count"` + CoverImage string `json:"cover_image"` + SocialImage string `json:"social_image"` + CanonicalUrl string `json:"canonical_url"` + CreatedAt *time.Time `json:"created_at"` + EditedAt *time.Time `json:"*"` + PublishedAt *time.Time `json:"published_at"` + LastCommentAt *time.Time `json:"last_comment_at"` + ReadingTimeMinutes int `json:"reading_time_minutes"` + TagList []string `json:"tag_list"` + Tags string `json:"tags"` + User User `json:"user"` +} + +type User struct { + Name string `json:"name"` + Username string `json:"username"` + TwitterUsername string `json:"twitter_username"` + GithubUsername string `json:"github_username"` + UserId int `json:"user_id"` + WebsiteUrl string `json:"website_url"` + ProfileImage string `json:"profile_image"` + ProfileImage90 string `json:"profile_image_90"` +} + +type GetArticlesPrams struct { + MostRecent bool + Page int + PerPage int + Tag string + Tags []string + TagsExclude []string + UserName string + State string + Top int + CollectionID int +} diff --git a/pkg/forem/service.go b/pkg/forem/service.go new file mode 100644 index 0000000..3060bfc --- /dev/null +++ b/pkg/forem/service.go @@ -0,0 +1,85 @@ +package forem + +import ( + "context" + "errors" + "fmt" + "github.com/go-resty/resty/v2" + "log/slog" + "net/http" + "strings" + "time" +) + +// Service docs: https://developers.forem.com/api +type Service struct { + httpClient *resty.Client +} + +func NewService(APIEndpoint string) *Service { + httpClient := resty.New() + httpClient. + SetRetryCount(12). + SetRetryWaitTime(5 * time.Second). + SetBaseURL(APIEndpoint).AddRetryCondition(func(response *resty.Response, err error) bool { + if err != nil { + return true + } + if response.StatusCode() == http.StatusInternalServerError || + response.StatusCode() == http.StatusBadGateway || + response.StatusCode() == http.StatusGatewayTimeout || + response.StatusCode() == http.StatusServiceUnavailable { + slog.Warn(fmt.Sprintf("Response status code is %d - Request: %s - Body: %s - Retrying...", response.StatusCode(), response.Request.URL, response.Body())) + return true + } + + return false + }) + return &Service{httpClient} +} + +// GetArticles Docs: https://developers.forem.com/api/v1#tag/articles/operation/getArticles +func (s *Service) GetArticles(ctx context.Context, params GetArticlesPrams) ([]Article, error) { + endpoint := "/api/articles" + if params.MostRecent { + endpoint = fmt.Sprintf("%s/latest", endpoint) + } + + var articles []Article + request := s.httpClient.R().SetContext(ctx).SetResult(&articles) + if params.Page > 0 { + request = request.SetQueryParam("page", fmt.Sprintf("%d", params.Page)) + } + if params.PerPage > 0 { + request = request.SetQueryParam("per_page", fmt.Sprintf("%d", params.PerPage)) + } + if params.Tag != "" { + request = request.SetQueryParam("tag", params.Tag) + } + if len(params.Tags) > 0 { + request = request.SetQueryParam("tags", strings.Join(params.Tags, ",")) + } + if len(params.TagsExclude) > 0 { + request = request.SetQueryParam("tags_exclude", strings.Join(params.TagsExclude, ",")) + } + if params.UserName != "" { + request = request.SetQueryParam("username", params.UserName) + } + if params.State != "" { + request = request.SetQueryParam("state", params.State) + } + if params.Top > 0 { + request = request.SetQueryParam("top", fmt.Sprintf("%d", params.Top)) + } + if params.CollectionID > 0 { + request = request.SetQueryParam("collection_id", fmt.Sprintf("%d", params.CollectionID)) + } + resp, err := request.Get(endpoint) + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, errors.New(fmt.Sprintf("Request: %s - Response code: %d - Response body: %s", resp.Request.URL, resp.StatusCode(), resp.Body())) + } + return articles, nil +} diff --git a/pkg/forem/service_test.go b/pkg/forem/service_test.go new file mode 100644 index 0000000..c21e4a2 --- /dev/null +++ b/pkg/forem/service_test.go @@ -0,0 +1,22 @@ +package forem + +import ( + "context" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGetArticles(t *testing.T) { + userName := "jacktt" + perPage := 10 + service := NewService(DevToEndpoint) + articles, err := service.GetArticles(context.Background(), GetArticlesPrams{ + UserName: userName, + PerPage: perPage, + }) + assert.NoError(t, err) + assert.Equal(t, 10, len(articles)) + for _, article := range articles { + assert.Equal(t, userName, article.User.Username) + } +}