feat(monitoring): match citations by canonical link and scope summary by brand/keyword/platform/date
Replace the domain+title fuzzy alias scoring with a canonical candidate-key index built from published-link aliases — only exact matches now resolve to a SaaS source, eliminating false positives across articles sharing a host (e.g. m.163.com vs www.163.com). Surface the summary scoping that already existed on loadCitationRanking / loadCitedArticles by accepting brand_id, keyword_id, ai_platform_id and business_date on CitationSummary, plumbing them through the handler and admin tracking view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -432,7 +432,7 @@ const enUS = {
|
|||||||
backToQuestions: "Back to questions",
|
backToQuestions: "Back to questions",
|
||||||
citationRanking: "Citation Ranking",
|
citationRanking: "Citation Ranking",
|
||||||
citationRankingTitle: "Citation Ranking",
|
citationRankingTitle: "Citation Ranking",
|
||||||
citationRankingHint: "Attribute SaaS-published content by registrable domain across model citation sources.",
|
citationRankingHint: "Attribute SaaS-published content by published-link identity across model citation sources.",
|
||||||
citationWindow7: "Last 7 days",
|
citationWindow7: "Last 7 days",
|
||||||
citationWindow30: "Last 30 days",
|
citationWindow30: "Last 30 days",
|
||||||
citationWindowLabel: "Last {days} days",
|
citationWindowLabel: "Last {days} days",
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ const zhCN = {
|
|||||||
backToQuestions: "返回问题列表",
|
backToQuestions: "返回问题列表",
|
||||||
citationRanking: "Citation Ranking",
|
citationRanking: "Citation Ranking",
|
||||||
citationRankingTitle: "引用排行",
|
citationRankingTitle: "引用排行",
|
||||||
citationRankingHint: "按主体域名归因 SaaS 发文在模型引用来源中的出现次数与占比。",
|
citationRankingHint: "按发布外链主体归因 SaaS 发文在模型引用来源中的出现次数与占比。",
|
||||||
citationWindow7: "近 7 天",
|
citationWindow7: "近 7 天",
|
||||||
citationWindow30: "近 30 天",
|
citationWindow30: "近 30 天",
|
||||||
citationWindowLabel: "近 {days} 天",
|
citationWindowLabel: "近 {days} 天",
|
||||||
|
|||||||
@@ -1078,6 +1078,10 @@ export const monitoringApi = {
|
|||||||
},
|
},
|
||||||
citationSummary(params: {
|
citationSummary(params: {
|
||||||
days?: number;
|
days?: number;
|
||||||
|
brand_id?: number;
|
||||||
|
keyword_id?: number | null;
|
||||||
|
business_date?: string;
|
||||||
|
ai_platform_id?: string;
|
||||||
}) {
|
}) {
|
||||||
return apiClient.get<MonitoringCitationSummaryResponse>("/api/tenant/monitoring/citation-summary", {
|
return apiClient.get<MonitoringCitationSummaryResponse>("/api/tenant/monitoring/citation-summary", {
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -251,10 +251,19 @@ const citationSummaryQuery = useQuery({
|
|||||||
queryKey: computed(() => [
|
queryKey: computed(() => [
|
||||||
"tracking",
|
"tracking",
|
||||||
"citation-summary",
|
"citation-summary",
|
||||||
|
selectedBrandId.value,
|
||||||
|
selectedKeywordId.value,
|
||||||
|
selectedPlatformId.value,
|
||||||
|
selectedBusinessDate.value,
|
||||||
selectedCitationWindowDays.value,
|
selectedCitationWindowDays.value,
|
||||||
]),
|
]),
|
||||||
|
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
monitoringApi.citationSummary({
|
monitoringApi.citationSummary({
|
||||||
|
brand_id: selectedBrandId.value ?? undefined,
|
||||||
|
keyword_id: selectedKeywordId.value,
|
||||||
|
business_date: selectedBusinessDate.value,
|
||||||
|
ai_platform_id: selectedPlatformId.value !== "all" ? selectedPlatformId.value : undefined,
|
||||||
days: selectedCitationWindowDays.value,
|
days: selectedCitationWindowDays.value,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ type monitoringAliasResolution struct {
|
|||||||
|
|
||||||
type monitoringAliasLookupInput struct {
|
type monitoringAliasLookupInput struct {
|
||||||
NormalizedURL string
|
NormalizedURL string
|
||||||
|
MatchKeys []string
|
||||||
RegistrableDomain string
|
RegistrableDomain string
|
||||||
SiteKey string
|
SiteKey string
|
||||||
Host string
|
Host string
|
||||||
@@ -293,6 +294,7 @@ type monitoringAliasLookupInput struct {
|
|||||||
|
|
||||||
type monitoringAliasCandidate struct {
|
type monitoringAliasCandidate struct {
|
||||||
NormalizedURL string
|
NormalizedURL string
|
||||||
|
MatchKeys []string
|
||||||
ArticleID *int64
|
ArticleID *int64
|
||||||
PublishRecordID *int64
|
PublishRecordID *int64
|
||||||
RegistrableDomain string
|
RegistrableDomain string
|
||||||
@@ -2444,6 +2446,7 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p
|
|||||||
}
|
}
|
||||||
lookupInputs = append(lookupInputs, monitoringAliasLookupInput{
|
lookupInputs = append(lookupInputs, monitoringAliasLookupInput{
|
||||||
NormalizedURL: key,
|
NormalizedURL: key,
|
||||||
|
MatchKeys: monitoringCitationCandidateKeys(item.URL, key),
|
||||||
RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)),
|
RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)),
|
||||||
SiteKey: strings.ToLower(strings.TrimSpace(siteKey)),
|
SiteKey: strings.ToLower(strings.TrimSpace(siteKey)),
|
||||||
Host: strings.ToLower(strings.TrimSpace(host)),
|
Host: strings.ToLower(strings.TrimSpace(host)),
|
||||||
@@ -2557,6 +2560,7 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
|
|||||||
candidate.Host = strings.ToLower(strings.TrimSpace(nullableStringText(host)))
|
candidate.Host = strings.ToLower(strings.TrimSpace(nullableStringText(host)))
|
||||||
candidate.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment))
|
candidate.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment))
|
||||||
candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle))
|
candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle))
|
||||||
|
candidate.MatchKeys = monitoringCitationCandidateKeys(candidate.NormalizedURL, candidate.NormalizedURL)
|
||||||
candidates = append(candidates, candidate)
|
candidates = append(candidates, candidate)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -2567,11 +2571,7 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
|
|||||||
bestScore := -1
|
bestScore := -1
|
||||||
bestAmbiguous := false
|
bestAmbiguous := false
|
||||||
var best monitoringAliasCandidate
|
var best monitoringAliasCandidate
|
||||||
domainMatchedArticleIDs := map[int64]struct{}{}
|
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if monitoringAliasDomainMatches(input, candidate) && candidate.ArticleID != nil {
|
|
||||||
domainMatchedArticleIDs[*candidate.ArticleID] = struct{}{}
|
|
||||||
}
|
|
||||||
score := scoreMonitoringAliasCandidate(input, candidate)
|
score := scoreMonitoringAliasCandidate(input, candidate)
|
||||||
if score < 0 {
|
if score < 0 {
|
||||||
continue
|
continue
|
||||||
@@ -2586,27 +2586,16 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
|
|||||||
bestAmbiguous = true
|
bestAmbiguous = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if bestScore == 40 && len(domainMatchedArticleIDs) == 1 {
|
|
||||||
bestScore = 60
|
|
||||||
}
|
|
||||||
|
|
||||||
if bestScore < 60 || bestAmbiguous {
|
if bestScore < 100 || bestAmbiguous {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
confidence := "medium"
|
|
||||||
status := "domain_matched"
|
|
||||||
if bestScore >= 100 {
|
|
||||||
confidence = "high"
|
|
||||||
status = "resolved"
|
|
||||||
} else if bestScore < 80 {
|
|
||||||
confidence = "low"
|
|
||||||
}
|
|
||||||
result[input.NormalizedURL] = monitoringAliasResolution{
|
result[input.NormalizedURL] = monitoringAliasResolution{
|
||||||
ArticleID: cloneInt64(best.ArticleID),
|
ArticleID: cloneInt64(best.ArticleID),
|
||||||
PublishRecordID: cloneInt64(best.PublishRecordID),
|
PublishRecordID: cloneInt64(best.PublishRecordID),
|
||||||
NormalizedURLKey: best.NormalizedURL,
|
NormalizedURLKey: best.NormalizedURL,
|
||||||
ResolutionStatus: status,
|
ResolutionStatus: "resolved",
|
||||||
ResolutionConfidence: confidence,
|
ResolutionConfidence: "high",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2618,23 +2607,19 @@ func scoreMonitoringAliasCandidate(input monitoringAliasLookupInput, candidate m
|
|||||||
return 100
|
return 100
|
||||||
}
|
}
|
||||||
|
|
||||||
if !monitoringAliasDomainMatches(input, candidate) {
|
for _, inputKey := range input.MatchKeys {
|
||||||
return -1
|
inputKey = strings.TrimSpace(inputKey)
|
||||||
}
|
if inputKey == "" {
|
||||||
|
continue
|
||||||
score := 40
|
}
|
||||||
if input.LastPathSegment != nil && strings.TrimSpace(*input.LastPathSegment) != "" && candidate.LastPathSegment == strings.TrimSpace(*input.LastPathSegment) {
|
for _, candidateKey := range candidate.MatchKeys {
|
||||||
score += 30
|
if inputKey == strings.TrimSpace(candidateKey) && candidateKey != "" {
|
||||||
}
|
return 100
|
||||||
if input.TitleKey != "" && candidate.TitleKey != "" {
|
}
|
||||||
switch {
|
|
||||||
case input.TitleKey == candidate.TitleKey:
|
|
||||||
score += 30
|
|
||||||
case strings.Contains(input.TitleKey, candidate.TitleKey) || strings.Contains(candidate.TitleKey, input.TitleKey):
|
|
||||||
score += 15
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return score
|
|
||||||
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool {
|
func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool {
|
||||||
|
|||||||
@@ -425,23 +425,33 @@ func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScoreMonitoringAliasCandidateUsesDomainWithTitleEvidence(t *testing.T) {
|
func TestScoreMonitoringAliasCandidateUsesCanonicalArticleKey(t *testing.T) {
|
||||||
lastPath := "article-42"
|
|
||||||
input := monitoringAliasLookupInput{
|
input := monitoringAliasLookupInput{
|
||||||
NormalizedURL: "https://m.example.com/article-42",
|
NormalizedURL: "https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html",
|
||||||
RegistrableDomain: "example.com",
|
MatchKeys: monitoringCitationCandidateKeys("https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
|
||||||
LastPathSegment: &lastPath,
|
|
||||||
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
|
|
||||||
}
|
}
|
||||||
candidate := monitoringAliasCandidate{
|
candidate := monitoringAliasCandidate{
|
||||||
NormalizedURL: "https://www.example.com/posts/article-42",
|
NormalizedURL: "https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html",
|
||||||
RegistrableDomain: "example.com",
|
MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
|
||||||
LastPathSegment: "article-42",
|
|
||||||
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if score := scoreMonitoringAliasCandidate(input, candidate); score < 90 {
|
if score := scoreMonitoringAliasCandidate(input, candidate); score != 100 {
|
||||||
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want strong domain/title match", score)
|
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want canonical URL match", score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreMonitoringAliasCandidateRejectsSameDomainDifferentArticle(t *testing.T) {
|
||||||
|
input := monitoringAliasLookupInput{
|
||||||
|
NormalizedURL: "https://www.163.com/dy/article/KIN5DDMK0556GQZ8.html",
|
||||||
|
MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KIN5DDMK0556GQZ8.html", ""),
|
||||||
|
}
|
||||||
|
candidate := monitoringAliasCandidate{
|
||||||
|
NormalizedURL: "https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html",
|
||||||
|
MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
if score := scoreMonitoringAliasCandidate(input, candidate); score != -1 {
|
||||||
|
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want no same-domain match", score)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type monitoringPublishedAlias struct {
|
||||||
|
ArticleID int64
|
||||||
|
PublishRecordID *int64
|
||||||
|
ArticleTitle string
|
||||||
|
PublishPlatform string
|
||||||
|
Ambiguous bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type monitoringPublishedAliasIndex map[string]monitoringPublishedAlias
|
||||||
|
|
||||||
|
type monitoringAliasQuerier interface {
|
||||||
|
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMonitoringPublishedAliasIndex(ctx context.Context, q monitoringAliasQuerier, tenantID int64) (monitoringPublishedAliasIndex, error) {
|
||||||
|
rows, err := q.Query(ctx, `
|
||||||
|
SELECT
|
||||||
|
article_id,
|
||||||
|
publish_record_id,
|
||||||
|
COALESCE(article_title_snapshot, '未命名文章') AS article_title,
|
||||||
|
COALESCE(publish_platform_name_snapshot, '已发布内容') AS publish_platform,
|
||||||
|
original_url,
|
||||||
|
normalized_url
|
||||||
|
FROM monitoring_article_url_aliases
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND confidence = 'high'
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
`, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, responseInternalAliasLookupError()
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
index := make(monitoringPublishedAliasIndex)
|
||||||
|
for rows.Next() {
|
||||||
|
var item monitoringPublishedAlias
|
||||||
|
var originalURL string
|
||||||
|
var normalizedURL string
|
||||||
|
var publishRecordID sql.NullInt64
|
||||||
|
if scanErr := rows.Scan(&item.ArticleID, &publishRecordID, &item.ArticleTitle, &item.PublishPlatform, &originalURL, &normalizedURL); scanErr != nil {
|
||||||
|
return nil, responseInternalAliasScanError()
|
||||||
|
}
|
||||||
|
item.PublishRecordID = nullableInt64Value(publishRecordID)
|
||||||
|
for _, key := range monitoringCitationCandidateKeys(originalURL, normalizedURL) {
|
||||||
|
existing, exists := index[key]
|
||||||
|
if exists && existing.ArticleID != item.ArticleID {
|
||||||
|
existing.Ambiguous = true
|
||||||
|
index[key] = existing
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
index[key] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, responseInternalAliasScanError()
|
||||||
|
}
|
||||||
|
|
||||||
|
return index, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index monitoringPublishedAliasIndex) Match(rawURL, normalizedURL string) (monitoringPublishedAlias, bool) {
|
||||||
|
for _, key := range monitoringCitationCandidateKeys(rawURL, normalizedURL) {
|
||||||
|
item, ok := index[key]
|
||||||
|
if ok && !item.Ambiguous {
|
||||||
|
return item, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return monitoringPublishedAlias{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringCitationCandidateKeys(rawURL, normalizedURL string) []string {
|
||||||
|
keys := []string{
|
||||||
|
monitoringCitationURLMatchKey(rawURL),
|
||||||
|
monitoringCitationURLMatchKey(normalizedURL),
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(keys))
|
||||||
|
seen := make(map[string]struct{}, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
result = append(result, key)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringCitationURLMatchKey(raw string) string {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(trimmed)
|
||||||
|
if err != nil || parsed.Hostname() == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
host := monitoringCanonicalCitationHost(parsed.Hostname())
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(host, "163.com") {
|
||||||
|
if id := monitoringNeteaseArticleID(trimmed); id != "" {
|
||||||
|
return "163.com/dy/article/" + strings.ToLower(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if host == "baijiahao.baidu.com" {
|
||||||
|
if id := extractBaijiahaoArticleIDFromURL(&trimmed); id != "" {
|
||||||
|
return host + "/s?id=" + strings.ToLower(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.TrimRight(parsed.EscapedPath(), "/")
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if host == "mp.weixin.qq.com" {
|
||||||
|
if strings.HasPrefix(path, "/s/") && len(path) > len("/s/") {
|
||||||
|
return host + path
|
||||||
|
}
|
||||||
|
if query := monitoringCanonicalQuery(parsed, []string{"__biz", "mid", "idx", "sn"}); query != "" {
|
||||||
|
return host + path + "?" + query
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "/" {
|
||||||
|
if query := monitoringCanonicalQuery(parsed, monitoringGenericContentQueryKeys()); query != "" {
|
||||||
|
return host + path + "?" + query
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if query := monitoringCanonicalQuery(parsed, monitoringGenericContentQueryKeys()); query != "" {
|
||||||
|
return host + path + "?" + query
|
||||||
|
}
|
||||||
|
return host + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringCanonicalCitationHost(value string) string {
|
||||||
|
host := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if withoutPort, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = withoutPort
|
||||||
|
}
|
||||||
|
host = strings.Trim(host, ".")
|
||||||
|
parts := strings.Split(host, ".")
|
||||||
|
if len(parts) <= 2 {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
switch parts[0] {
|
||||||
|
case "www", "m", "wap", "mobile", "3g", "amp":
|
||||||
|
return strings.Join(parts[1:], ".")
|
||||||
|
default:
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringCanonicalQuery(parsed *url.URL, allowedKeys []string) string {
|
||||||
|
if parsed == nil || len(allowedKeys) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
values := parsed.Query()
|
||||||
|
pairs := make([]string, 0, len(allowedKeys))
|
||||||
|
for _, key := range allowedKeys {
|
||||||
|
value := strings.TrimSpace(values.Get(key))
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pairs = append(pairs, url.QueryEscape(key)+"="+url.QueryEscape(value))
|
||||||
|
}
|
||||||
|
sort.Strings(pairs)
|
||||||
|
return strings.Join(pairs, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringGenericContentQueryKeys() []string {
|
||||||
|
return []string{"id", "article_id", "articleId", "docid", "docId", "postId"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringNeteaseArticleID(rawURL string) string {
|
||||||
|
if id := extractWangyihaoArticleIDFromURL(&rawURL); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||||
|
for index := len(segments) - 1; index >= 0; index-- {
|
||||||
|
id := strings.TrimSuffix(strings.TrimSpace(segments[index]), ".html")
|
||||||
|
if monitoringLooksLikeNeteaseArticleID(id) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringLooksLikeNeteaseArticleID(value string) bool {
|
||||||
|
if len(value) < 8 || len(value) > 32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasDigit := false
|
||||||
|
hasLetter := false
|
||||||
|
for _, r := range value {
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
hasDigit = true
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
hasLetter = true
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
hasLetter = true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasDigit && hasLetter
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseInternalAliasLookupError() error {
|
||||||
|
return response.ErrInternal(50041, "alias_lookup_failed", "failed to resolve article aliases")
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseInternalAliasScanError() error {
|
||||||
|
return response.ErrInternal(50041, "alias_scan_failed", "failed to parse article alias resolution")
|
||||||
|
}
|
||||||
@@ -414,29 +414,55 @@ func (s *MonitoringService) DashboardComposite(
|
|||||||
func (s *MonitoringService) CitationSummary(
|
func (s *MonitoringService) CitationSummary(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
days int,
|
days int,
|
||||||
|
brandID int64,
|
||||||
|
keywordID *int64,
|
||||||
|
businessDate string,
|
||||||
|
aiPlatformID *string,
|
||||||
) (*MonitoringCitationSummaryResponse, error) {
|
) (*MonitoringCitationSummaryResponse, error) {
|
||||||
actor := auth.MustActor(ctx)
|
actor := auth.MustActor(ctx)
|
||||||
workspaceID := auth.CurrentWorkspaceID(ctx)
|
workspaceID := auth.CurrentWorkspaceID(ctx)
|
||||||
|
|
||||||
|
var questionIDs []int64
|
||||||
|
if brandID != 0 {
|
||||||
|
brand, err := s.resolveBrand(ctx, actor.TenantID, brandID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if keywordID != nil {
|
||||||
|
if err := s.ensureKeywordBelongsToBrand(ctx, actor.TenantID, brand.ID, *keywordID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configuredQuestions, err := s.loadConfiguredQuestions(ctx, actor.TenantID, brand.ID, keywordID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
questionIDs = configuredQuestionIDs(configuredQuestions)
|
||||||
|
}
|
||||||
|
|
||||||
quota, err := s.loadQuota(ctx, actor, workspaceID)
|
quota, err := s.loadQuota(ctx, actor, workspaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
citationWindowDays := normalizeCitationWindowDays(days)
|
citationWindowDays := normalizeCitationWindowDays(days)
|
||||||
startDate, endDate := trackingDateWindow(citationWindowDays)
|
startDate, endDate, err := resolveDashboardDateWindow(citationWindowDays, businessDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, endDate)
|
accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, endDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, 0, nil, startDate, endDate, accessStates, nil)
|
selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID)
|
||||||
|
citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, accessStates, selectedPlatformID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, 0, nil, startDate, endDate, nil)
|
citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, selectedPlatformID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -2481,98 +2507,10 @@ func (s *MonitoringService) loadCitationRanking(
|
|||||||
}
|
}
|
||||||
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
||||||
|
|
||||||
rows, err := s.monitoringPool.Query(ctx, `
|
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||||
WITH published_domains AS (
|
|
||||||
SELECT DISTINCT domain_key
|
|
||||||
FROM (
|
|
||||||
SELECT NULLIF(LOWER(TRIM(host)), '') AS domain_key
|
|
||||||
FROM monitoring_article_url_aliases
|
|
||||||
WHERE tenant_id = $1
|
|
||||||
UNION
|
|
||||||
SELECT NULLIF(LOWER(TRIM(site_key)), '') AS domain_key
|
|
||||||
FROM monitoring_article_url_aliases
|
|
||||||
WHERE tenant_id = $1
|
|
||||||
) domains
|
|
||||||
WHERE domain_key IS NOT NULL
|
|
||||||
),
|
|
||||||
run_counts AS (
|
|
||||||
SELECT
|
|
||||||
r.ai_platform_id,
|
|
||||||
COUNT(*) AS sample_count
|
|
||||||
FROM question_monitor_runs r
|
|
||||||
WHERE r.tenant_id = $1
|
|
||||||
AND ($2::bigint = 0 OR r.brand_id = $2)
|
|
||||||
AND r.collector_type = $3
|
|
||||||
AND r.status = 'succeeded'
|
|
||||||
AND r.business_date BETWEEN $4::date AND $5::date
|
|
||||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
|
||||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
|
||||||
GROUP BY r.ai_platform_id
|
|
||||||
),
|
|
||||||
citation_sources AS (
|
|
||||||
SELECT
|
|
||||||
r.ai_platform_id,
|
|
||||||
cf.id,
|
|
||||||
cf.run_id,
|
|
||||||
cf.article_id,
|
|
||||||
(
|
|
||||||
cf.article_id IS NOT NULL
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM published_domains pd
|
|
||||||
WHERE pd.domain_key = ANY(ARRAY[
|
|
||||||
LOWER(NULLIF(cf.registrable_domain, '')),
|
|
||||||
LOWER(NULLIF(cf.site_key, '')),
|
|
||||||
LOWER(NULLIF(cf.host, ''))
|
|
||||||
])
|
|
||||||
)
|
|
||||||
) AS matched_saas_source
|
|
||||||
FROM question_monitor_runs r
|
|
||||||
JOIN monitoring_citation_facts cf
|
|
||||||
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
|
||||||
WHERE r.tenant_id = $1
|
|
||||||
AND ($2::bigint = 0 OR r.brand_id = $2)
|
|
||||||
AND r.collector_type = $3
|
|
||||||
AND r.status = 'succeeded'
|
|
||||||
AND r.business_date BETWEEN $4::date AND $5::date
|
|
||||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
|
||||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
|
||||||
),
|
|
||||||
citation_counts AS (
|
|
||||||
SELECT
|
|
||||||
ai_platform_id,
|
|
||||||
COUNT(*) AS citation_source_count,
|
|
||||||
COUNT(*) FILTER (WHERE matched_saas_source AND ai_platform_id <> 'qwen') AS saas_source_count,
|
|
||||||
COUNT(DISTINCT run_id) FILTER (WHERE matched_saas_source AND ai_platform_id <> 'qwen') AS cited_answer_count,
|
|
||||||
COUNT(DISTINCT article_id) FILTER (WHERE article_id IS NOT NULL AND ai_platform_id <> 'qwen') AS cited_article_count
|
|
||||||
FROM citation_sources
|
|
||||||
GROUP BY ai_platform_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
rc.ai_platform_id,
|
|
||||||
rc.sample_count,
|
|
||||||
COALESCE(cc.cited_answer_count, 0) AS cited_answer_count,
|
|
||||||
COALESCE(cc.cited_article_count, 0) AS cited_article_count,
|
|
||||||
COALESCE(cc.citation_source_count, 0) AS citation_source_count,
|
|
||||||
COALESCE(cc.saas_source_count, 0) AS saas_source_count,
|
|
||||||
CASE
|
|
||||||
WHEN COALESCE(cc.citation_source_count, 0) > 0
|
|
||||||
THEN COALESCE(cc.saas_source_count, 0)::double precision / cc.citation_source_count::double precision
|
|
||||||
ELSE NULL
|
|
||||||
END AS saas_source_rate,
|
|
||||||
CASE
|
|
||||||
WHEN rc.sample_count > 0 THEN COALESCE(cc.cited_answer_count, 0)::double precision / rc.sample_count
|
|
||||||
ELSE NULL
|
|
||||||
END AS citation_rate
|
|
||||||
FROM run_counts rc
|
|
||||||
LEFT JOIN citation_counts cc ON cc.ai_platform_id = rc.ai_platform_id
|
|
||||||
WHERE COALESCE(cc.saas_source_count, 0) > 0
|
|
||||||
ORDER BY cited_answer_count DESC, saas_source_count DESC, cited_article_count DESC, rc.ai_platform_id ASC
|
|
||||||
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
type citationRankingAggregate struct {
|
type citationRankingAggregate struct {
|
||||||
CitedAnswerCount int64
|
CitedAnswerCount int64
|
||||||
@@ -2583,14 +2521,29 @@ func (s *MonitoringService) loadCitationRanking(
|
|||||||
}
|
}
|
||||||
|
|
||||||
aggregates := make(map[string]citationRankingAggregate)
|
aggregates := make(map[string]citationRankingAggregate)
|
||||||
|
rows, err := s.monitoringPool.Query(ctx, `
|
||||||
|
SELECT
|
||||||
|
r.ai_platform_id,
|
||||||
|
COUNT(*) AS sample_count
|
||||||
|
FROM question_monitor_runs r
|
||||||
|
WHERE r.tenant_id = $1
|
||||||
|
AND ($2::bigint = 0 OR r.brand_id = $2)
|
||||||
|
AND r.collector_type = $3
|
||||||
|
AND r.status = 'succeeded'
|
||||||
|
AND r.business_date BETWEEN $4::date AND $5::date
|
||||||
|
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||||
|
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||||
|
GROUP BY r.ai_platform_id
|
||||||
|
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var platformID string
|
var platformID string
|
||||||
var sampleCount int64
|
var sampleCount int64
|
||||||
var citedAnswerCount int64
|
if scanErr := rows.Scan(&platformID, &sampleCount); scanErr != nil {
|
||||||
var citedArticleCount int64
|
|
||||||
var citationSourceCount int64
|
|
||||||
var saasSourceCount int64
|
|
||||||
if scanErr := rows.Scan(&platformID, &sampleCount, &citedAnswerCount, &citedArticleCount, &citationSourceCount, &saasSourceCount, new(sql.NullFloat64), new(sql.NullFloat64)); scanErr != nil {
|
|
||||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
|
||||||
}
|
}
|
||||||
platformID = normalizeMonitoringPlatformID(platformID)
|
platformID = normalizeMonitoringPlatformID(platformID)
|
||||||
@@ -2598,16 +2551,80 @@ func (s *MonitoringService) loadCitationRanking(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
item := aggregates[platformID]
|
item := aggregates[platformID]
|
||||||
item.CitedAnswerCount += citedAnswerCount
|
|
||||||
item.CitedArticleCount += citedArticleCount
|
|
||||||
item.CitationSourceCount += citationSourceCount
|
|
||||||
item.SaaSSourceCount += saasSourceCount
|
|
||||||
item.SampleCount += sampleCount
|
item.SampleCount += sampleCount
|
||||||
aggregates[platformID] = item
|
aggregates[platformID] = item
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citation ranking")
|
||||||
|
}
|
||||||
|
|
||||||
|
citedRuns := make(map[string]map[int64]struct{})
|
||||||
|
citedArticles := make(map[string]map[int64]struct{})
|
||||||
|
rows, err = s.monitoringPool.Query(ctx, `
|
||||||
|
SELECT
|
||||||
|
r.ai_platform_id,
|
||||||
|
cf.run_id,
|
||||||
|
cf.cited_url,
|
||||||
|
cf.normalized_url
|
||||||
|
FROM question_monitor_runs r
|
||||||
|
JOIN monitoring_citation_facts cf
|
||||||
|
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
||||||
|
WHERE r.tenant_id = $1
|
||||||
|
AND ($2::bigint = 0 OR r.brand_id = $2)
|
||||||
|
AND r.collector_type = $3
|
||||||
|
AND r.status = 'succeeded'
|
||||||
|
AND r.business_date BETWEEN $4::date AND $5::date
|
||||||
|
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||||
|
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||||
|
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var platformID string
|
||||||
|
var runID int64
|
||||||
|
var citedURL string
|
||||||
|
var normalizedURL string
|
||||||
|
if scanErr := rows.Scan(&platformID, &runID, &citedURL, &normalizedURL); scanErr != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
|
||||||
|
}
|
||||||
|
platformID = normalizeMonitoringPlatformID(platformID)
|
||||||
|
if platformID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := aggregates[platformID]
|
||||||
|
item.CitationSourceCount++
|
||||||
|
if platformID != "qwen" {
|
||||||
|
if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
|
||||||
|
item.SaaSSourceCount++
|
||||||
|
if _, exists := citedRuns[platformID]; !exists {
|
||||||
|
citedRuns[platformID] = make(map[int64]struct{})
|
||||||
|
}
|
||||||
|
citedRuns[platformID][runID] = struct{}{}
|
||||||
|
if _, exists := citedArticles[platformID]; !exists {
|
||||||
|
citedArticles[platformID] = make(map[int64]struct{})
|
||||||
|
}
|
||||||
|
citedArticles[platformID][alias.ArticleID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aggregates[platformID] = item
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citation ranking")
|
||||||
|
}
|
||||||
|
for platformID, item := range aggregates {
|
||||||
|
item.CitedAnswerCount = int64(len(citedRuns[platformID]))
|
||||||
|
item.CitedArticleCount = int64(len(citedArticles[platformID]))
|
||||||
|
aggregates[platformID] = item
|
||||||
|
}
|
||||||
|
|
||||||
items := make([]MonitoringCitationRanking, 0, len(aggregates))
|
items := make([]MonitoringCitationRanking, 0, len(aggregates))
|
||||||
for platformID, aggregate := range aggregates {
|
for platformID, aggregate := range aggregates {
|
||||||
|
if aggregate.SaaSSourceCount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
items = append(items, MonitoringCitationRanking{
|
items = append(items, MonitoringCitationRanking{
|
||||||
AIPlatformID: platformID,
|
AIPlatformID: platformID,
|
||||||
PlatformName: platformDisplayName(platformID),
|
PlatformName: platformDisplayName(platformID),
|
||||||
@@ -2648,6 +2665,11 @@ func (s *MonitoringService) loadCitedArticles(
|
|||||||
}
|
}
|
||||||
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
||||||
|
|
||||||
|
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var totalSampleCount int64
|
var totalSampleCount int64
|
||||||
if err := s.monitoringPool.QueryRow(ctx, `
|
if err := s.monitoringPool.QueryRow(ctx, `
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
@@ -2664,78 +2686,78 @@ func (s *MonitoringService) loadCitedArticles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.monitoringPool.Query(ctx, `
|
rows, err := s.monitoringPool.Query(ctx, `
|
||||||
WITH article_counts AS (
|
|
||||||
SELECT
|
|
||||||
cf.article_id,
|
|
||||||
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
|
|
||||||
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
|
|
||||||
COUNT(*) AS citation_count
|
|
||||||
FROM monitoring_citation_facts cf
|
|
||||||
JOIN question_monitor_runs r
|
|
||||||
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT
|
|
||||||
alias.article_title_snapshot,
|
|
||||||
alias.publish_platform_name_snapshot
|
|
||||||
FROM monitoring_article_url_aliases alias
|
|
||||||
WHERE alias.tenant_id = cf.tenant_id
|
|
||||||
AND alias.article_id = cf.article_id
|
|
||||||
ORDER BY
|
|
||||||
(alias.publish_record_id = cf.publish_record_id) DESC NULLS LAST,
|
|
||||||
alias.updated_at DESC,
|
|
||||||
alias.id DESC
|
|
||||||
LIMIT 1
|
|
||||||
) alias ON TRUE
|
|
||||||
WHERE cf.tenant_id = $1
|
|
||||||
AND ($2::bigint = 0 OR cf.brand_id = $2)
|
|
||||||
AND cf.article_id IS NOT NULL
|
|
||||||
AND r.ai_platform_id <> 'qwen'
|
|
||||||
AND r.collector_type = $3
|
|
||||||
AND r.status = 'succeeded'
|
|
||||||
AND r.business_date BETWEEN $4::date AND $5::date
|
|
||||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
|
||||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
|
||||||
GROUP BY cf.article_id
|
|
||||||
),
|
|
||||||
totals AS (
|
|
||||||
SELECT COALESCE(SUM(citation_count), 0) AS total_article_citation_count
|
|
||||||
FROM article_counts
|
|
||||||
)
|
|
||||||
SELECT
|
SELECT
|
||||||
cf.article_id,
|
cf.cited_url,
|
||||||
cf.article_title,
|
cf.normalized_url
|
||||||
cf.publish_platform,
|
FROM monitoring_citation_facts cf
|
||||||
cf.citation_count,
|
JOIN question_monitor_runs r
|
||||||
t.total_article_citation_count
|
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
|
||||||
FROM article_counts cf
|
WHERE cf.tenant_id = $1
|
||||||
CROSS JOIN totals t
|
AND ($2::bigint = 0 OR cf.brand_id = $2)
|
||||||
ORDER BY citation_count DESC, cf.article_id ASC
|
AND r.ai_platform_id <> 'qwen'
|
||||||
LIMIT 10
|
AND r.collector_type = $3
|
||||||
|
AND r.status = 'succeeded'
|
||||||
|
AND r.business_date BETWEEN $4::date AND $5::date
|
||||||
|
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||||
|
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||||
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles")
|
return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles")
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
items := make([]MonitoringCitedArticle, 0)
|
type citedArticleAggregate struct {
|
||||||
|
ArticleID int64
|
||||||
|
ArticleTitle string
|
||||||
|
PublishPlatform string
|
||||||
|
CitationCount int64
|
||||||
|
}
|
||||||
|
aggregates := make(map[int64]citedArticleAggregate)
|
||||||
|
var totalArticleCitationCount int64
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var articleID int64
|
var citedURL string
|
||||||
var title string
|
var normalizedURL string
|
||||||
var publishPlatform string
|
if scanErr := rows.Scan(&citedURL, &normalizedURL); scanErr != nil {
|
||||||
var citationCount int64
|
|
||||||
var totalArticleCitationCount int64
|
|
||||||
if scanErr := rows.Scan(&articleID, &title, &publishPlatform, &citationCount, &totalArticleCitationCount); scanErr != nil {
|
|
||||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
|
||||||
}
|
}
|
||||||
|
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := aggregates[alias.ArticleID]
|
||||||
|
if item.ArticleID == 0 {
|
||||||
|
item.ArticleID = alias.ArticleID
|
||||||
|
item.ArticleTitle = alias.ArticleTitle
|
||||||
|
item.PublishPlatform = alias.PublishPlatform
|
||||||
|
}
|
||||||
|
item.CitationCount++
|
||||||
|
totalArticleCitationCount++
|
||||||
|
aggregates[alias.ArticleID] = item
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles")
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]MonitoringCitedArticle, 0, len(aggregates))
|
||||||
|
for _, item := range aggregates {
|
||||||
items = append(items, MonitoringCitedArticle{
|
items = append(items, MonitoringCitedArticle{
|
||||||
ArticleID: articleID,
|
ArticleID: item.ArticleID,
|
||||||
ArticleTitle: title,
|
ArticleTitle: item.ArticleTitle,
|
||||||
PublishPlatform: publishPlatform,
|
PublishPlatform: item.PublishPlatform,
|
||||||
CitationCount: citationCount,
|
CitationCount: item.CitationCount,
|
||||||
CitationRate: divideAsPointer(citationCount, totalSampleCount),
|
CitationRate: divideAsPointer(item.CitationCount, totalSampleCount),
|
||||||
SourceShare: divideAsPointer(citationCount, totalArticleCitationCount),
|
SourceShare: divideAsPointer(item.CitationCount, totalArticleCitationCount),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
sort.Slice(items, func(left, right int) bool {
|
||||||
|
if items[left].CitationCount != items[right].CitationCount {
|
||||||
|
return items[left].CitationCount > items[right].CitationCount
|
||||||
|
}
|
||||||
|
return items[left].ArticleID < items[right].ArticleID
|
||||||
|
})
|
||||||
|
if len(items) > 10 {
|
||||||
|
items = items[:10]
|
||||||
|
}
|
||||||
|
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
@@ -2923,6 +2945,11 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := s.monitoringPool.Query(ctx, `
|
rows, err := s.monitoringPool.Query(ctx, `
|
||||||
WITH filtered_citations AS (
|
WITH filtered_citations AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -2930,10 +2957,10 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
|||||||
cf.run_id,
|
cf.run_id,
|
||||||
cf.cited_url,
|
cf.cited_url,
|
||||||
cf.cited_title,
|
cf.cited_title,
|
||||||
|
cf.normalized_url,
|
||||||
cf.host,
|
cf.host,
|
||||||
cf.registrable_domain,
|
cf.registrable_domain,
|
||||||
cf.site_key,
|
cf.site_key,
|
||||||
cf.article_id,
|
|
||||||
cf.resolution_status,
|
cf.resolution_status,
|
||||||
cf.resolution_confidence
|
cf.resolution_confidence
|
||||||
FROM monitoring_citation_facts cf
|
FROM monitoring_citation_facts cf
|
||||||
@@ -2979,10 +3006,9 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
|||||||
fc.run_id,
|
fc.run_id,
|
||||||
fc.cited_url,
|
fc.cited_url,
|
||||||
fc.cited_title,
|
fc.cited_title,
|
||||||
|
fc.normalized_url,
|
||||||
COALESCE(dm.mapped_site_name, fc.site_key, fc.registrable_domain) AS site_name,
|
COALESCE(dm.mapped_site_name, fc.site_key, fc.registrable_domain) AS site_name,
|
||||||
COALESCE(dm.mapped_site_key, fc.site_key) AS site_key,
|
COALESCE(dm.mapped_site_key, fc.site_key) AS site_key,
|
||||||
fc.article_id,
|
|
||||||
alias.article_title_snapshot AS article_title,
|
|
||||||
fc.resolution_status,
|
fc.resolution_status,
|
||||||
fc.resolution_confidence
|
fc.resolution_confidence
|
||||||
FROM filtered_citations fc
|
FROM filtered_citations fc
|
||||||
@@ -2990,8 +3016,6 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
|||||||
ON dm.host = fc.host
|
ON dm.host = fc.host
|
||||||
AND dm.registrable_domain = fc.registrable_domain
|
AND dm.registrable_domain = fc.registrable_domain
|
||||||
AND dm.site_key = fc.site_key
|
AND dm.site_key = fc.site_key
|
||||||
LEFT JOIN monitoring_article_url_aliases alias
|
|
||||||
ON alias.tenant_id = $1 AND alias.article_id = fc.article_id
|
|
||||||
ORDER BY fc.run_id ASC, fc.id ASC
|
ORDER BY fc.run_id ASC, fc.id ASC
|
||||||
`, tenantID, runIDs)
|
`, tenantID, runIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -3003,21 +3027,26 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
|||||||
var runID int64
|
var runID int64
|
||||||
var citedURL string
|
var citedURL string
|
||||||
var citedTitle sql.NullString
|
var citedTitle sql.NullString
|
||||||
|
var normalizedURL string
|
||||||
var siteName string
|
var siteName string
|
||||||
var siteKey string
|
var siteKey string
|
||||||
var articleID sql.NullInt64
|
|
||||||
var articleTitle sql.NullString
|
|
||||||
var resolutionStatus string
|
var resolutionStatus string
|
||||||
var resolutionConfidence string
|
var resolutionConfidence string
|
||||||
if scanErr := rows.Scan(&runID, &citedURL, &citedTitle, &siteName, &siteKey, &articleID, &articleTitle, &resolutionStatus, &resolutionConfidence); scanErr != nil {
|
if scanErr := rows.Scan(&runID, &citedURL, &citedTitle, &normalizedURL, &siteName, &siteKey, &resolutionStatus, &resolutionConfidence); scanErr != nil {
|
||||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citations")
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citations")
|
||||||
}
|
}
|
||||||
siteName = repairMonitoringMojibake(siteName)
|
siteName = repairMonitoringMojibake(siteName)
|
||||||
if citedTitle.Valid {
|
if citedTitle.Valid {
|
||||||
citedTitle.String = repairMonitoringMojibake(citedTitle.String)
|
citedTitle.String = repairMonitoringMojibake(citedTitle.String)
|
||||||
}
|
}
|
||||||
if articleTitle.Valid {
|
var articleID *int64
|
||||||
articleTitle.String = repairMonitoringMojibake(articleTitle.String)
|
var articleTitle *string
|
||||||
|
if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
|
||||||
|
articleID = &alias.ArticleID
|
||||||
|
title := repairMonitoringMojibake(alias.ArticleTitle)
|
||||||
|
articleTitle = &title
|
||||||
|
resolutionStatus = "resolved"
|
||||||
|
resolutionConfidence = "high"
|
||||||
}
|
}
|
||||||
result[runID] = append(result[runID], MonitoringQuestionDetailCitation{
|
result[runID] = append(result[runID], MonitoringQuestionDetailCitation{
|
||||||
CitedURL: citedURL,
|
CitedURL: citedURL,
|
||||||
@@ -3025,12 +3054,15 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
|||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
SiteKey: siteKey,
|
SiteKey: siteKey,
|
||||||
FaviconURL: faviconURL(siteKey),
|
FaviconURL: faviconURL(siteKey),
|
||||||
ArticleID: nullableInt64Value(articleID),
|
ArticleID: articleID,
|
||||||
ArticleTitle: nullableStringValue(articleTitle),
|
ArticleTitle: articleTitle,
|
||||||
ResolutionStatus: resolutionStatus,
|
ResolutionStatus: resolutionStatus,
|
||||||
ResolutionConfidence: resolutionConfidence,
|
ResolutionConfidence: resolutionConfidence,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citations")
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -3040,15 +3072,21 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
|
|||||||
return []MonitoringQuestionCitationStats{}, nil
|
return []MonitoringQuestionCitationStats{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := s.monitoringPool.Query(ctx, `
|
rows, err := s.monitoringPool.Query(ctx, `
|
||||||
WITH filtered_facts AS (
|
WITH filtered_facts AS (
|
||||||
SELECT
|
SELECT
|
||||||
r.ai_platform_id,
|
r.ai_platform_id,
|
||||||
cf.id,
|
cf.id,
|
||||||
|
cf.cited_url,
|
||||||
|
cf.normalized_url,
|
||||||
cf.host,
|
cf.host,
|
||||||
cf.registrable_domain,
|
cf.registrable_domain,
|
||||||
cf.site_key,
|
cf.site_key
|
||||||
cf.article_id
|
|
||||||
FROM question_monitor_runs r
|
FROM question_monitor_runs r
|
||||||
JOIN monitoring_citation_facts cf
|
JOIN monitoring_citation_facts cf
|
||||||
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
||||||
@@ -3090,49 +3128,20 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
|
|||||||
ORDER BY LENGTH(mapping.registrable_domain) DESC, mapping.id DESC
|
ORDER BY LENGTH(mapping.registrable_domain) DESC, mapping.id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) dm ON TRUE
|
) dm ON TRUE
|
||||||
),
|
|
||||||
grouped AS (
|
|
||||||
SELECT
|
|
||||||
ff.ai_platform_id,
|
|
||||||
COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain) AS site_name,
|
|
||||||
COALESCE(dm.mapped_site_key, ff.site_key) AS site_key,
|
|
||||||
COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain) AS site_domain,
|
|
||||||
COUNT(ff.id) AS citation_count,
|
|
||||||
COUNT(*) FILTER (WHERE ff.article_id IS NOT NULL AND ff.ai_platform_id <> 'qwen') AS content_citation_count
|
|
||||||
FROM filtered_facts ff
|
|
||||||
LEFT JOIN domain_mappings dm
|
|
||||||
ON dm.host = ff.host
|
|
||||||
AND dm.registrable_domain = ff.registrable_domain
|
|
||||||
AND dm.site_key = ff.site_key
|
|
||||||
GROUP BY
|
|
||||||
ff.ai_platform_id,
|
|
||||||
COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain),
|
|
||||||
COALESCE(dm.mapped_site_key, ff.site_key),
|
|
||||||
COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain)
|
|
||||||
),
|
|
||||||
totals AS (
|
|
||||||
SELECT
|
|
||||||
ai_platform_id,
|
|
||||||
SUM(citation_count) AS total_citation_count
|
|
||||||
FROM grouped
|
|
||||||
GROUP BY ai_platform_id
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
g.ai_platform_id,
|
ff.ai_platform_id,
|
||||||
g.site_name,
|
ff.cited_url,
|
||||||
g.site_key,
|
ff.normalized_url,
|
||||||
g.site_domain,
|
COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain) AS site_name,
|
||||||
g.citation_count,
|
COALESCE(dm.mapped_site_key, ff.site_key) AS site_key,
|
||||||
CASE
|
COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain) AS site_domain
|
||||||
WHEN t.total_citation_count > 0
|
FROM filtered_facts ff
|
||||||
THEN g.citation_count::double precision / t.total_citation_count::double precision
|
LEFT JOIN domain_mappings dm
|
||||||
ELSE NULL
|
ON dm.host = ff.host
|
||||||
END AS citation_rate,
|
AND dm.registrable_domain = ff.registrable_domain
|
||||||
g.content_citation_count
|
AND dm.site_key = ff.site_key
|
||||||
FROM grouped g
|
ORDER BY ff.ai_platform_id ASC, ff.id ASC
|
||||||
JOIN totals t
|
|
||||||
ON t.ai_platform_id = g.ai_platform_id
|
|
||||||
ORDER BY g.ai_platform_id ASC, g.citation_count DESC, g.site_name ASC
|
|
||||||
`, tenantID, runIDs)
|
`, tenantID, runIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis")
|
return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis")
|
||||||
@@ -3148,12 +3157,12 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
|
|||||||
totals := make(map[string]int64)
|
totals := make(map[string]int64)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var platformID string
|
var platformID string
|
||||||
|
var citedURL string
|
||||||
|
var normalizedURL string
|
||||||
var siteName string
|
var siteName string
|
||||||
var siteKey string
|
var siteKey string
|
||||||
var siteDomain string
|
var siteDomain string
|
||||||
var citationCount int64
|
if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL, &siteName, &siteKey, &siteDomain); scanErr != nil {
|
||||||
var contentCitationCount int64
|
|
||||||
if scanErr := rows.Scan(&platformID, &siteName, &siteKey, &siteDomain, &citationCount, new(sql.NullFloat64), &contentCitationCount); scanErr != nil {
|
|
||||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis")
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis")
|
||||||
}
|
}
|
||||||
platformID = normalizeMonitoringPlatformID(platformID)
|
platformID = normalizeMonitoringPlatformID(platformID)
|
||||||
@@ -3174,10 +3183,17 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
|
|||||||
SiteDomain: siteDomain,
|
SiteDomain: siteDomain,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item.CitationCount += citationCount
|
item.CitationCount++
|
||||||
item.ContentCitationCount += contentCitationCount
|
if platformID != "qwen" {
|
||||||
|
if _, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
|
||||||
|
item.ContentCitationCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
aggregates[key] = item
|
aggregates[key] = item
|
||||||
totals[platformID] += citationCount
|
totals[platformID]++
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate question citation analysis")
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]MonitoringQuestionCitationStats, 0, len(aggregates))
|
items := make([]MonitoringQuestionCitationStats, 0, len(aggregates))
|
||||||
@@ -3206,40 +3222,23 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
|
|||||||
return []MonitoringQuestionContentCitation{}, nil
|
return []MonitoringQuestionContentCitation{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := s.monitoringPool.Query(ctx, `
|
rows, err := s.monitoringPool.Query(ctx, `
|
||||||
WITH platform_totals AS (
|
|
||||||
SELECT
|
|
||||||
r.ai_platform_id,
|
|
||||||
COUNT(cf.id) AS total_citation_count
|
|
||||||
FROM question_monitor_runs r
|
|
||||||
JOIN monitoring_citation_facts cf
|
|
||||||
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
|
||||||
WHERE r.tenant_id = $1
|
|
||||||
AND r.status = 'succeeded'
|
|
||||||
AND r.id = ANY($2)
|
|
||||||
GROUP BY r.ai_platform_id
|
|
||||||
)
|
|
||||||
SELECT
|
SELECT
|
||||||
cf.article_id,
|
|
||||||
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
|
|
||||||
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
|
|
||||||
r.ai_platform_id,
|
r.ai_platform_id,
|
||||||
COUNT(*) AS citation_count,
|
cf.cited_url,
|
||||||
MAX(pt.total_citation_count) AS total_citation_count
|
cf.normalized_url
|
||||||
FROM question_monitor_runs r
|
FROM question_monitor_runs r
|
||||||
JOIN monitoring_citation_facts cf
|
JOIN monitoring_citation_facts cf
|
||||||
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
||||||
JOIN platform_totals pt
|
|
||||||
ON pt.ai_platform_id = r.ai_platform_id
|
|
||||||
LEFT JOIN monitoring_article_url_aliases alias
|
|
||||||
ON alias.tenant_id = cf.tenant_id AND alias.article_id = cf.article_id
|
|
||||||
WHERE r.tenant_id = $1
|
WHERE r.tenant_id = $1
|
||||||
AND r.status = 'succeeded'
|
AND r.status = 'succeeded'
|
||||||
AND cf.article_id IS NOT NULL
|
|
||||||
AND r.ai_platform_id <> 'qwen'
|
AND r.ai_platform_id <> 'qwen'
|
||||||
AND r.id = ANY($2)
|
AND r.id = ANY($2)
|
||||||
GROUP BY cf.article_id, r.ai_platform_id
|
|
||||||
ORDER BY citation_count DESC, cf.article_id ASC
|
|
||||||
`, tenantID, runIDs)
|
`, tenantID, runIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations")
|
return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations")
|
||||||
@@ -3254,37 +3253,40 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
|
|||||||
aggregates := make(map[contentCitationKey]MonitoringQuestionContentCitation)
|
aggregates := make(map[contentCitationKey]MonitoringQuestionContentCitation)
|
||||||
totals := make(map[string]int64)
|
totals := make(map[string]int64)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var articleID int64
|
|
||||||
var articleTitle string
|
|
||||||
var publishPlatform string
|
|
||||||
var platformID string
|
var platformID string
|
||||||
var citationCount int64
|
var citedURL string
|
||||||
var totalCitationCount int64
|
var normalizedURL string
|
||||||
if scanErr := rows.Scan(&articleID, &articleTitle, &publishPlatform, &platformID, &citationCount, &totalCitationCount); scanErr != nil {
|
if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL); scanErr != nil {
|
||||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations")
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations")
|
||||||
}
|
}
|
||||||
platformID = normalizeMonitoringPlatformID(platformID)
|
platformID = normalizeMonitoringPlatformID(platformID)
|
||||||
if platformID == "" {
|
if platformID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
totals[platformID]++
|
||||||
|
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
key := contentCitationKey{
|
key := contentCitationKey{
|
||||||
PlatformID: platformID,
|
PlatformID: platformID,
|
||||||
ArticleID: articleID,
|
ArticleID: alias.ArticleID,
|
||||||
}
|
}
|
||||||
item := aggregates[key]
|
item := aggregates[key]
|
||||||
if item.ArticleID == 0 {
|
if item.ArticleID == 0 {
|
||||||
item = MonitoringQuestionContentCitation{
|
item = MonitoringQuestionContentCitation{
|
||||||
ArticleID: articleID,
|
ArticleID: alias.ArticleID,
|
||||||
ArticleTitle: articleTitle,
|
ArticleTitle: alias.ArticleTitle,
|
||||||
PublishPlatform: publishPlatform,
|
PublishPlatform: alias.PublishPlatform,
|
||||||
AIPlatformID: platformID,
|
AIPlatformID: platformID,
|
||||||
PlatformName: platformDisplayName(platformID),
|
PlatformName: platformDisplayName(platformID),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item.CitationCount += citationCount
|
item.CitationCount++
|
||||||
aggregates[key] = item
|
aggregates[key] = item
|
||||||
totals[platformID] += citationCount
|
}
|
||||||
_ = totalCitationCount
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate content citations")
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]MonitoringQuestionContentCitation, 0, len(aggregates))
|
items := make([]MonitoringQuestionContentCitation, 0, len(aggregates))
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ func TestMonitoringAliasOriginalURLUsesWangyihaoPublicURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMonitoringCitationURLMatchKeyCollapsesNeteaseHostPrefix(t *testing.T) {
|
||||||
|
left := monitoringCitationURLMatchKey("https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html?from=test")
|
||||||
|
right := monitoringCitationURLMatchKey("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html")
|
||||||
|
|
||||||
|
const want = "163.com/dy/article/krpre8dj0556mcr0"
|
||||||
|
if left != want || right != want {
|
||||||
|
t.Fatalf("monitoringCitationURLMatchKey() = %q and %q; want %q", left, right, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizePublishStatusKeepsPartialSuccess(t *testing.T) {
|
func TestNormalizePublishStatusKeepsPartialSuccess(t *testing.T) {
|
||||||
if got := normalizePublishStatus("partial_success"); got != "partial_success" {
|
if got := normalizePublishStatus("partial_success"); got != "partial_success" {
|
||||||
t.Fatalf("normalizePublishStatus(partial_success) = %q, want partial_success", got)
|
t.Fatalf("normalizePublishStatus(partial_success) = %q, want partial_success", got)
|
||||||
|
|||||||
@@ -66,7 +66,21 @@ func (h *MonitoringHandler) CitationSummary(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days)
|
brandID, err := parseOptionalInt64(c.Query("brand_id"))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40031, "invalid_brand_id", "brand_id must be a number"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keywordID, err := parseOptionalInt64Pointer(c.Query("keyword_id"))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40031, "invalid_keyword_id", "keyword_id must be a number"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
|
||||||
|
|
||||||
|
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days, brandID, keywordID, c.Query("business_date"), aiPlatformID)
|
||||||
if svcErr != nil {
|
if svcErr != nil {
|
||||||
response.Error(c, svcErr)
|
response.Error(c, svcErr)
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user