парсер

This commit is contained in:
Artem Tsyrulnikov
2026-01-06 22:15:49 +03:00
parent 2c0ae2dfc3
commit a8be39005b
10 changed files with 237 additions and 17 deletions

View File

@@ -123,9 +123,12 @@ async def _handle_chat_join_request(event: dict) -> None:
return return
from_user = event.get('from') from_user = event.get('from')
if not from_user: if not from_user or not from_user.get('id'):
log.error('No user in chat_join_request event') user_chat_id = event.get('user_chat_id')
return if not user_chat_id:
log.error('No user or user_chat_id in chat_join_request event')
return
from_user = {'id': user_chat_id}
invite_link_obj = event.get('invite_link', {}) invite_link_obj = event.get('invite_link', {})
invite_link = invite_link_obj.get('invite_link') invite_link = invite_link_obj.get('invite_link')
@@ -153,7 +156,8 @@ async def _handle_chat_member_updated(event: dict) -> None:
return return
from_user = event.get('from') from_user = event.get('from')
if not from_user: member_user = event.get('new_chat_member', {}).get('user')
if not member_user:
log.error('No user in chat_member_updated event') log.error('No user in chat_member_updated event')
return return
@@ -181,16 +185,16 @@ async def _handle_chat_member_updated(event: dict) -> None:
return return
await deps.get_usecase().handle_subscription( await deps.get_usecase().handle_subscription(
user_telegram_id=from_user['id'], user_telegram_id=member_user['id'],
username=from_user.get('username'), username=member_user.get('username'),
invite_link=invite_link, invite_link=invite_link,
first_name=from_user.get('first_name'), first_name=member_user.get('first_name'),
last_name=from_user.get('last_name'), last_name=member_user.get('last_name'),
) )
elif user_left: elif user_left:
await deps.get_usecase().handle_unsubscription( await deps.get_usecase().handle_unsubscription(
user_telegram_id=from_user['id'], user_telegram_id=member_user['id'],
channel_telegram_id=chat['id'], channel_telegram_id=chat['id'],
) )

View File

@@ -42,6 +42,13 @@ async def create_placement(
raise domain.ChannelNotFound(project.channel.id) raise domain.ChannelNotFound(project.channel.id)
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
log.info(
'Created invite link for project=%s channel_telegram_id=%s requires_approval=%s invite_link=%s',
project.id,
project.channel.telegram_id,
requires_approval,
invite_link,
)
placement = domain.Placement( placement = domain.Placement(
project_id=input.project_id, project_id=input.project_id,

View File

@@ -64,4 +64,4 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) ->
post.id, post.id,
) )
log.info('Fetch placement post cycle completed. Created %s placements', created_count) log.debug('Fetch placement post cycle completed. Created %s placements', created_count)

View File

@@ -121,6 +121,7 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
title=channel.title, title=channel.title,
username=channel.username, username=channel.username,
status=project.status, status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default,
) )
# Если >1 workspace - создаем/обновляем канал и отправляем уведомление # Если >1 workspace - создаем/обновляем канал и отправляем уведомление

View File

@@ -97,11 +97,6 @@ async def create_purchase(
if not creative or creative.project_id != project.id: if not creative or creative.project_id != project.id:
raise domain.CreativeNotFound(input.creative_id) raise domain.CreativeNotFound(input.creative_id)
existing_purchase = await self.database.get_active_purchase(project.id, creative.id)
if existing_purchase:
channels = await self.database.get_purchase_channels(existing_purchase.id)
return _build_purchase_output(existing_purchase, channels)
details = input.details details = input.details
purchase = domain.Purchase( purchase = domain.Purchase(
workspace_id=workspace_id, workspace_id=workspace_id,
@@ -143,6 +138,13 @@ async def create_purchase(
log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id) log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id)
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
log.info(
'Created invite link for purchase %s channel_telegram_id=%s requires_approval=%s invite_link=%s',
purchase.id,
project.channel.telegram_id,
requires_approval,
invite_link,
)
channel_details = channel_input.details channel_details = channel_input.details
purchase_channel = await self.database.add_channel_to_purchase( purchase_channel = await self.database.add_channel_to_purchase(

View File

@@ -2,6 +2,7 @@ package bot
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
@@ -222,6 +223,11 @@ func (b *Bot) Update(u *echotron.Update) {
if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil { if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
go func() { go func() {
ctx := context.Background() ctx := context.Background()
if raw, err := json.Marshal(u); err == nil {
log.Info().RawJSON("update", raw).Msg("System event payload")
} else {
log.Error().Err(err).Msg("Failed to marshal system event payload")
}
err := b.Backend.ForwardTelegramUpdate(ctx, u) err := b.Backend.ForwardTelegramUpdate(ctx, u)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to forward system event to backend") log.Error().Err(err).Msg("Failed to forward system event to backend")

View File

@@ -1433,6 +1433,7 @@ func (s *PurchaseOptionalDetails) sendCreativeWithInviteLink(
creative *backend.Creative, creative *backend.Creative,
inviteLink string, inviteLink string,
) { ) {
log.Info().Str("invite_link", inviteLink).Msg("Sending creative with invite link")
linkedText := s.injectInviteLink(creative.Text, inviteLink) linkedText := s.injectInviteLink(creative.Text, inviteLink)
editor := CreativeEditorFields{ editor := CreativeEditorFields{
Text: &linkedText, Text: &linkedText,

View File

@@ -59,7 +59,8 @@ func extractPosts(channel domain.Channel, msgs []tg.MessageClass) []domain.Post
continue continue
} }
p := domain.NewPost(channel, m.ID, m.Message, m.Views) text := messageToHTML(m.Message, m.Entities)
p := domain.NewPost(channel, m.ID, text, m.Views)
posts = append(posts, p) posts = append(posts, p)
} }

View File

@@ -38,7 +38,8 @@ func (t *Telegram) GetChannelHistory(ctx context.Context, channel domain.Channel
continue continue
} }
p := domain.NewPost(channel, m.ID, m.Message, m.Views) text := messageToHTML(m.Message, m.Entities)
p := domain.NewPost(channel, m.ID, text, m.Views)
placements = append(placements, p) placements = append(placements, p)
} }

View File

@@ -0,0 +1,197 @@
package telegram
import (
"html"
"sort"
"strconv"
"strings"
"unicode/utf8"
"github.com/gotd/td/tg"
)
type htmlEntity struct {
start int
end int
openTag string
closeTag string
length int
}
type htmlEvent struct {
pos int
tag string
isStart bool
length int
}
func messageToHTML(text string, entities []tg.MessageEntityClass) string {
if text == "" {
return ""
}
if len(entities) == 0 {
return html.EscapeString(text)
}
ranges := make([]htmlEntity, 0, len(entities))
needed := make([]int, 0, len(entities)*2)
for _, raw := range entities {
offset, length, openTag, closeTag, ok := htmlEntityMeta(raw)
if !ok {
continue
}
start := offset
end := offset + length
if length <= 0 {
continue
}
ranges = append(ranges, htmlEntity{
start: start,
end: end,
openTag: openTag,
closeTag: closeTag,
length: length,
})
needed = append(needed, start, end)
}
if len(ranges) == 0 {
return html.EscapeString(text)
}
positions := utf16PositionsToBytes(text, needed)
events := make([]htmlEvent, 0, len(ranges)*2)
for _, r := range ranges {
startByte, okStart := positions[r.start]
endByte, okEnd := positions[r.end]
if !okStart || !okEnd || startByte > endByte {
continue
}
events = append(events, htmlEvent{
pos: startByte,
tag: r.openTag,
isStart: true,
length: r.length,
})
events = append(events, htmlEvent{
pos: endByte,
tag: r.closeTag,
isStart: false,
length: r.length,
})
}
sort.SliceStable(events, func(i, j int) bool {
if events[i].pos != events[j].pos {
return events[i].pos < events[j].pos
}
if events[i].isStart != events[j].isStart {
return !events[i].isStart
}
if events[i].isStart {
return events[i].length > events[j].length
}
return events[i].length < events[j].length
})
var b strings.Builder
last := 0
for _, ev := range events {
if ev.pos > last {
b.WriteString(html.EscapeString(text[last:ev.pos]))
}
b.WriteString(ev.tag)
last = ev.pos
}
if last < len(text) {
b.WriteString(html.EscapeString(text[last:]))
}
return b.String()
}
func htmlEntityMeta(entity tg.MessageEntityClass) (offset int, length int, openTag string, closeTag string, ok bool) {
switch e := entity.(type) {
case *tg.MessageEntityBold:
return e.Offset, e.Length, "<b>", "</b>", true
case *tg.MessageEntityItalic:
return e.Offset, e.Length, "<i>", "</i>", true
case *tg.MessageEntityUnderline:
return e.Offset, e.Length, "<u>", "</u>", true
case *tg.MessageEntityStrike:
return e.Offset, e.Length, "<s>", "</s>", true
case *tg.MessageEntityCode:
return e.Offset, e.Length, "<code>", "</code>", true
case *tg.MessageEntityPre:
if e.Language != "" {
lang := html.EscapeString(e.Language)
return e.Offset, e.Length, `<pre><code class="language-` + lang + `">`, "</code></pre>", true
}
return e.Offset, e.Length, "<pre>", "</pre>", true
case *tg.MessageEntityTextURL:
url := html.EscapeString(e.URL)
return e.Offset, e.Length, `<a href="` + url + `">`, "</a>", true
case *tg.MessageEntityMentionName:
userID := html.EscapeString(strconv.FormatInt(e.UserID, 10))
return e.Offset, e.Length, `<a href="tg://user?id=` + userID + `">`, "</a>", true
case *tg.MessageEntitySpoiler:
return e.Offset, e.Length, `<span class="tg-spoiler">`, "</span>", true
case *tg.MessageEntityBlockquote:
if e.Collapsed {
return e.Offset, e.Length, `<blockquote expandable>`, "</blockquote>", true
}
return e.Offset, e.Length, "<blockquote>", "</blockquote>", true
case *tg.MessageEntityCustomEmoji:
id := html.EscapeString(strconv.FormatInt(e.DocumentID, 10))
return e.Offset, e.Length, `<tg-emoji emoji-id="` + id + `">`, "</tg-emoji>", true
default:
return 0, 0, "", "", false
}
}
func utf16PositionsToBytes(text string, needed []int) map[int]int {
result := make(map[int]int, len(needed))
needSet := make(map[int]struct{}, len(needed))
for _, n := range needed {
needSet[n] = struct{}{}
}
utf16Pos := 0
if _, ok := needSet[0]; ok {
result[0] = 0
}
for i, r := range text {
if _, ok := needSet[utf16Pos]; ok {
result[utf16Pos] = i
}
step := utf16RuneLen(r)
if step == 2 {
if _, ok := needSet[utf16Pos+1]; ok {
result[utf16Pos+1] = i
}
}
utf16Pos += step
}
if _, ok := needSet[utf16Pos]; ok {
result[utf16Pos] = len(text)
}
return result
}
func utf16RuneLen(r rune) int {
const surrSelf = 0x10000
if r >= surrSelf && r <= utf8.MaxRune {
return 2
}
return 1
}