парсер

This commit is contained in:
Artem Tsyrulnikov
2026-01-06 21:04:42 +03:00
parent b77e4fd0b6
commit 2c0ae2dfc3
45 changed files with 2202 additions and 51 deletions

View File

@@ -0,0 +1,90 @@
package httpserver
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/TelegramExchange/tgex-parser/internal/usecase"
"github.com/gotd/td/tgerr"
"github.com/rs/zerolog/log"
)
type Server struct {
httpServer *http.Server
}
func New(uc *usecase.UseCase, addr string) *Server {
mux := http.NewServeMux()
mux.HandleFunc("/fetch-telegram-channel", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := strings.TrimSpace(r.URL.Query().Get("username"))
username = strings.TrimPrefix(username, "@")
if username == "" {
http.Error(w, "missing username", http.StatusBadRequest)
return
}
channel, err := uc.FetchChannelMeta(r.Context(), username)
if err != nil {
if isNotFoundError(err) {
http.Error(w, "channel not found", http.StatusNotFound)
return
}
log.Error().Err(err).Str("username", username).Msg("fetch channel meta failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
resp := struct {
ID string `json:"id"`
TelegramID int64 `json:"telegram_id"`
Username string `json:"username"`
Title string `json:"title"`
AccessHash int64 `json:"access_hash"`
Pts int `json:"pts"`
}{
ID: channel.ID.String(),
TelegramID: channel.TelegramID,
Username: channel.Username,
Title: channel.Title,
AccessHash: channel.AccessHash,
Pts: channel.Pts,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Error().Err(err).Msg("encode channel response")
}
})
return &Server{
httpServer: &http.Server{
Addr: addr,
Handler: mux,
},
}
}
func (s *Server) ListenAndServe() error {
return s.httpServer.ListenAndServe()
}
func (s *Server) Shutdown(ctx context.Context) error {
return s.httpServer.Shutdown(ctx)
}
func isNotFoundError(err error) bool {
if tgerr.Is(err, "USERNAME_NOT_OCCUPIED", "USERNAME_INVALID", "CHANNEL_INVALID", "CHANNEL_PRIVATE") {
return true
}
msg := err.Error()
return strings.Contains(msg, "no chats in resolve result") ||
strings.Contains(msg, "not a channel")
}

View File

@@ -0,0 +1,85 @@
package worker
import (
"context"
"sync"
"time"
"github.com/rs/zerolog/log"
"github.com/TelegramExchange/tgex-parser/internal/usecase"
)
type ChannelConfig struct {
MaxWorkers int `envconfig:"WORKER__MAX_WORKERS" default:"1"`
ChannelDelay time.Duration `envconfig:"WORKER__CHANNEL_DELAY" default:"500ms"`
RequestTimeout time.Duration `envconfig:"WORKER__REQUEST_TIMEOUT" default:"10s"`
MessagesLimit int `envconfig:"WORKER__MESSAGES_LIMIT" default:"20"`
PollInterval time.Duration `envconfig:"WORKER__POLL_INTERVAL" default:"20s"`
}
type ChannelWorker struct {
usecase *usecase.UseCase
config ChannelConfig
stop chan struct{}
done chan struct{}
}
func NewChannelWorker(uc *usecase.UseCase, cfg ChannelConfig) *ChannelWorker {
w := &ChannelWorker{
usecase: uc,
config: cfg,
stop: make(chan struct{}),
done: make(chan struct{}),
}
go w.run()
return w
}
func (w *ChannelWorker) run() {
log.Info().Msg("channel worker: started")
var wg sync.WaitGroup
wg.Add(w.config.MaxWorkers)
for range w.config.MaxWorkers {
go w.spawnWorker(&wg)
}
wg.Wait()
log.Info().Msg("channel worker: stopped")
close(w.done)
}
func (w *ChannelWorker) spawnWorker(wg *sync.WaitGroup) {
defer wg.Done()
limiter := time.NewTicker(w.config.ChannelDelay)
defer limiter.Stop()
poll := time.NewTicker(w.config.PollInterval)
defer poll.Stop()
for {
select {
case <-w.stop:
return
case <-poll.C:
err := w.usecase.FetchChannels(context.Background())
if err != nil {
log.Error().Err(err).Msg("usecase.FetchChannels error")
}
}
}
}
func (w *ChannelWorker) Stop() {
close(w.stop)
<-w.done
}

View File

@@ -0,0 +1,65 @@
package worker
import (
"context"
"time"
"github.com/rs/zerolog/log"
"github.com/TelegramExchange/tgex-parser/internal/usecase"
)
type ViewsConfig struct {
Interval time.Duration `envconfig:"WORKER__VIEWS_INTERVAL" default:"30s"`
RequestTimeout time.Duration `envconfig:"WORKER__REQUEST_TIMEOUT" default:"10s"`
}
type ViewsWorker struct {
usecase *usecase.UseCase
config ViewsConfig
stop chan struct{}
done chan struct{}
}
func NewViewsWorker(uc *usecase.UseCase, cfg ViewsConfig) *ViewsWorker {
w := &ViewsWorker{
usecase: uc,
config: cfg,
stop: make(chan struct{}),
done: make(chan struct{}),
}
go w.run()
return w
}
func (w *ViewsWorker) run() {
log.Info().Msg("views worker: started")
for {
select {
case <-w.stop:
log.Info().Msg("views worker: stopped")
close(w.done)
return
case <-time.After(w.config.Interval):
log.Debug().Dur("interval", w.config.Interval).Msg("views worker: refresh tick")
ctx, cancel := context.WithTimeout(context.Background(), w.config.RequestTimeout)
err := w.usecase.FetchViews(ctx)
if err != nil {
log.Error().Err(err).Msg("views worker: FetchViews error")
} else {
log.Debug().Msg("views worker: refresh finished")
}
cancel()
}
}
}
func (w *ViewsWorker) Stop() {
close(w.stop)
<-w.done
}