feat: port logic from Java original to align with current Java behavior

- Switch Telegram parse mode to HTML to match Java client.
- Rename command identifiers to Java's: delgroup/delapple/delgoogle/
  checkappscore/rawappleapp/rawgoogleapp.
- Move admin singleton to "common" collection at _id="admin".
- Store group _id as string-of-int64 to match Java AbstractModel schema.
- Add `class` discriminator field on persisted models.
- Expand AppleAppResponse (42 fields) and GoogleAppResponse (63 fields,
  with nested Category/Feature) to match Java records.
- Add AppleAppRequest (track id or bundle id) / GoogleAppRequest types.
- Implement Raw* commands as Telegram document attachments instead of
  truncated text.
- Group-authorization gate (admin.HasGroup(chatId)) on non-admin commands.
- Cache TTL via millis timestamp; round score to 1 decimal; weekend silent
  send in scheduler.
- Match Java table renderer: " │ " separators, "─┼─" rows every 5 lines.
- Prefer MONGODB_CONNECTION_STRING env (Java parity); auto-extract DB
  name from URI with fallback to "store-scraper-bot".

Note: This is an AI-assisted port (not human-verified). Behavior parity
with the Java implementation has not been tested end-to-end; treat as a
starting point.
This commit is contained in:
tiennm99 committed 2026-04-25 20:26:25 +07:00
1 parent cfc8d67641
commit e695d9b223
34 files changed
+1132 -1149

No files matched your search

+78 -76
View File
@@ -5,108 +5,110 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/miti99/store-scraper-bot-go/internal/api/google/request"
"github.com/miti99/store-scraper-bot-go/internal/config"
"github.com/miti99/store-scraper-bot-go/internal/model"
"github.com/miti99/store-scraper-bot-go/internal/repository"
"go.uber.org/zap"
)
const googleAPIURL = "https://store-scraper.vercel.app/google/app"
type GoogleAppRequest struct {
AppID string `json:"appId"`
Country string `json:"country"`
}
// BaseURL mirrors Java GooglePlayScraper (api/google/GooglePlayScraper.java).
const BaseURL = "https://store-scraper.vercel.app/google"
type GoogleScraper struct {
httpClient *http.Client
appRepo *repository.GoogleAppRepository
logger *zap.Logger
repo *repository.GoogleAppRepository
cfg *config.Config
client *http.Client
logger *zap.Logger
}
func NewGoogleScraper(appRepo *repository.GoogleAppRepository, cfg *config.Config) *GoogleScraper {
func NewGoogleScraper(repo *repository.GoogleAppRepository, cfg *config.Config) *GoogleScraper {
return &GoogleScraper{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
appRepo: appRepo,
logger: cfg.Logger,
repo: repo,
cfg: cfg,
client: &http.Client{Timeout: 30 * time.Second},
logger: cfg.Logger,
}
}
func (s *GoogleScraper) GetApp(appID, country string) (*model.GoogleAppResponse, error) {
// Check cache first
cachedApp, err := s.appRepo.GetCached(appID)
func (s *GoogleScraper) RawApp(req request.GoogleAppRequest) (string, error) {
body, err := json.Marshal(req)
if err != nil {
s.logger.Error("Failed to get cached google app", zap.Error(err), zap.String("appId", appID))
return "", fmt.Errorf("marshal google request: %w", err)
}
if cachedApp != nil {
s.logger.Debug("Returning cached google app", zap.String("appId", appID))
return &cachedApp.App, nil
httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, BaseURL+"/app", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("build google request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
// Fetch from API
s.logger.Info("Fetching google app from API", zap.String("appId", appID), zap.String("country", country))
response, err := s.fetchFromAPI(appID, country)
resp, err := s.client.Do(httpReq)
if err != nil {
return "", fmt.Errorf("google HTTP error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("google HTTP status %d", resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read google body: %w", err)
}
return string(raw), nil
}
func (s *GoogleScraper) App(req request.GoogleAppRequest) (*model.GoogleAppResponse, error) {
raw, err := s.RawApp(req)
if err != nil {
return nil, err
}
out := &model.GoogleAppResponse{}
if err := json.Unmarshal([]byte(raw), out); err != nil {
return nil, fmt.Errorf("decode google response: %w", err)
}
return out, nil
}
// Save to cache
func (s *GoogleScraper) GetApp(appID, country string) (*model.GoogleAppResponse, error) {
if cached, _ := s.repo.GetCached(appID); cached != nil {
return &cached.App, nil
}
resp, err := s.App(request.New(appID, country))
if err != nil {
return nil, err
}
s.cache(resp, appID)
return resp, nil
}
func (s *GoogleScraper) FetchAndCache(req request.GoogleAppRequest) (*model.GoogleAppResponse, error) {
resp, err := s.App(req)
if err != nil {
return nil, err
}
s.cache(resp, req.AppID)
return resp, nil
}
func (s *GoogleScraper) cache(resp *model.GoogleAppResponse, fallbackID string) {
if resp == nil {
return
}
id := resp.AppID
if id == "" {
id = fallbackID
}
if id == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
googleApp := model.NewGoogleApp(appID, *response)
if err := s.appRepo.Save(ctx, googleApp); err != nil {
s.logger.Error("Failed to save google app to cache", zap.Error(err), zap.String("appId", appID))
entry := model.NewGoogleApp(id, *resp, time.Now().UnixMilli())
if err := s.repo.Save(ctx, entry); err != nil {
s.logger.Warn("failed to cache google app", zap.String("appId", id), zap.Error(err))
}
return response, nil
}
func (s *GoogleScraper) fetchFromAPI(appID, country string) (*model.GoogleAppResponse, error) {
request := GoogleAppRequest{
AppID: appID,
Country: country,
}
requestBody, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", googleAPIURL, bytes.NewBuffer(requestBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode)
}
var response model.GoogleAppResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &response, nil
}
func (s *GoogleScraper) GetLastUpdate(appID, country string) (int64, error) {
app, err := s.GetApp(appID, country)
if err != nil {
return 0, err
}
return app.Updated, nil
}