Skip to content

Commit f6b461d

Browse files
authored
feat: add 1px transparent image in RSS item descriptions for telemetry content views (#44)
### What this PR does? 传统网站即使使用 Umami 等访问统计工具,也难以覆盖 RSS 订阅用户的阅读行为,导致访问量数据不完整,影响对内容表现的准确评估。为解决这一短板,我们新增了在 RSS 订阅中统计访问量的功能支持。 通过在每个 RSS 条目的内容中插入 1 像素透明图片,系统可匿名统计订阅内容的实际阅读量。这种轻量化设计不会影响用户体验,帮助内容创作者更全面的了解内容阅读量情况。 Umami 适配参考 halo-sigs/plugin-umami#30 ```release-note 为 RSS 订阅内容统计访问量提供扩展支持,本插件并不提供任何存储和分析访问量的功能但允许其他插件扩展并获取访问量数据上报给诸如 Umami 之类的应用 ```
1 parent d55dd55 commit f6b461d

File tree

13 files changed

+696
-12
lines changed

13 files changed

+696
-12
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package run.halo.feed;
2+
3+
import java.util.Objects;
4+
import lombok.Data;
5+
import lombok.Getter;
6+
import lombok.experimental.Accessors;
7+
import org.springframework.http.HttpHeaders;
8+
import org.springframework.lang.NonNull;
9+
10+
@Data
11+
@Accessors(chain = true)
12+
public class TelemetryEventInfo {
13+
private String pageUrl;
14+
private String screen;
15+
private String language;
16+
private String languageRegion;
17+
private String title;
18+
private String referrer;
19+
private String ip;
20+
private String userAgent;
21+
private String browser;
22+
private String os;
23+
24+
@Getter(onMethod_ = @NonNull)
25+
private HttpHeaders headers;
26+
27+
@Override
28+
public boolean equals(Object o) {
29+
if (this == o) {
30+
return true;
31+
}
32+
if (o == null || getClass() != o.getClass()) {
33+
return false;
34+
}
35+
TelemetryEventInfo that = (TelemetryEventInfo) o;
36+
return Objects.equals(pageUrl, that.pageUrl) && Objects.equals(title, that.title)
37+
&& Objects.equals(referrer, that.referrer) && Objects.equals(ip, that.ip)
38+
&& Objects.equals(userAgent, that.userAgent);
39+
}
40+
41+
@Override
42+
public int hashCode() {
43+
return Objects.hash(pageUrl, title, referrer, ip, userAgent);
44+
}
45+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package run.halo.feed;
2+
3+
import org.pf4j.ExtensionPoint;
4+
5+
public interface TelemetryRecorder extends ExtensionPoint {
6+
7+
void record(TelemetryEventInfo eventInfo);
8+
}

app/src/main/java/run/halo/feed/RssCacheManager.java

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,23 @@ public Mono<String> get(String key, Mono<RSS2> loader) {
3535
}
3636

3737
private Mono<String> generateRssXml(Mono<RSS2> loader) {
38-
var builder = new RssXmlBuilder();
38+
var builder = new RssXmlBuilder()
39+
.withGenerator("Halo v2.0");
40+
3941
var rssMono = loader.doOnNext(builder::withRss2);
40-
var generatorMono = getRssGenerator()
41-
.doOnNext(builder::withGenerator);
42+
43+
var generatorMono = systemInfoGetter.get()
44+
.doOnNext(info -> builder.withExternalUrl(info.getUrl().toString())
45+
.withGenerator("Halo v" + info.getVersion().toStableVersion().toString())
46+
);
47+
4248
var extractTagsMono = BasicProp.getBasicProp(settingFetcher)
4349
.doOnNext(prop -> builder.withExtractRssTags(prop.getRssExtraTags()));
50+
4451
return Mono.when(rssMono, generatorMono, extractTagsMono)
4552
.then(Mono.fromSupplier(builder::toXmlString));
4653
}
4754

48-
private Mono<String> getRssGenerator() {
49-
return systemInfoGetter.get()
50-
.map(info -> "Halo v" + info.getVersion().toStableVersion().toString())
51-
.defaultIfEmpty("Halo v2.0");
52-
}
53-
5455
@EventListener(PluginConfigUpdatedEvent.class)
5556
public void onPluginConfigUpdated() {
5657
cache.invalidateAll();

app/src/main/java/run/halo/feed/RssXmlBuilder.java

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.google.common.base.Throwables;
44
import java.io.StringReader;
5+
import java.nio.charset.StandardCharsets;
56
import java.time.Instant;
67
import java.time.ZoneOffset;
78
import java.time.format.DateTimeFormatter;
@@ -14,13 +15,17 @@
1415
import org.dom4j.Element;
1516
import org.dom4j.io.SAXReader;
1617
import org.springframework.util.CollectionUtils;
18+
import org.springframework.web.util.UriComponentsBuilder;
19+
import org.springframework.web.util.UriUtils;
20+
import run.halo.feed.telemetry.TelemetryEndpoint;
1721

1822
@Slf4j
1923
public class RssXmlBuilder {
2024
private RSS2 rss2;
2125
private String generator = "Halo v2.0";
2226
private String extractRssTags;
2327
private Instant lastBuildDate = Instant.now();
28+
private String externalUrl;
2429

2530
public RssXmlBuilder withRss2(RSS2 rss2) {
2631
this.rss2 = rss2;
@@ -48,6 +53,11 @@ RssXmlBuilder withLastBuildDate(Instant lastBuildDate) {
4853
return this;
4954
}
5055

56+
RssXmlBuilder withExternalUrl(String externalUrl) {
57+
this.externalUrl = externalUrl;
58+
return this;
59+
}
60+
5161
public String toXmlString() {
5262
Document document = DocumentHelper.createDocument();
5363

@@ -127,18 +137,19 @@ private Element parseXmlString(String xml) throws DocumentException {
127137
}
128138
}
129139

130-
private static void createItemElementsToChannel(Element channel, List<RSS2.Item> items) {
140+
private void createItemElementsToChannel(Element channel, List<RSS2.Item> items) {
131141
if (CollectionUtils.isEmpty(items)) {
132142
return;
133143
}
134144
items.forEach(item -> createItemElementToChannel(channel, item));
135145
}
136146

137-
private static void createItemElementToChannel(Element channel, RSS2.Item item) {
147+
private void createItemElementToChannel(Element channel, RSS2.Item item) {
138148
Element itemElement = channel.addElement("item");
139149
itemElement.addElement("title").addCDATA(item.getTitle());
140150
itemElement.addElement("link").addText(item.getLink());
141-
itemElement.addElement("description").addCDATA(item.getDescription());
151+
var description = getDescriptionWithTelemetry(item);
152+
itemElement.addElement("description").addCDATA(description);
142153
itemElement.addElement("guid")
143154
.addAttribute("isPermaLink", "false")
144155
.addText(item.getGuid());
@@ -201,6 +212,29 @@ private static void createItemElementToChannel(Element channel, RSS2.Item item)
201212
});
202213
}
203214

215+
private String getDescriptionWithTelemetry(RSS2.Item item) {
216+
if (StringUtils.isBlank(externalUrl)) {
217+
return item.getDescription();
218+
}
219+
var uri = UriComponentsBuilder.fromUriString(item.getLink())
220+
.build();
221+
var telemetryBaseUri = externalUrl + TelemetryEndpoint.TELEMETRY_PATH;
222+
var telemetryUri = UriComponentsBuilder.fromUriString(telemetryBaseUri)
223+
.queryParam("title", UriUtils.encode(item.getTitle(), StandardCharsets.UTF_8))
224+
.queryParam("url", uri.getPath())
225+
.build(true)
226+
.toUriString();
227+
228+
// Build the telemetry image HTML
229+
var telemetryImageHtml = String.format(
230+
"<img src=\"%s\" width=\"1\" height=\"1\" alt=\"\" style=\"opacity:0;\" />",
231+
telemetryUri
232+
);
233+
234+
// Append telemetry image to description
235+
return telemetryImageHtml + item.getDescription();
236+
}
237+
204238
static <T> List<T> nullSafeList(List<T> list) {
205239
return list == null ? List.of() : list;
206240
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package run.halo.feed.telemetry;
2+
3+
import java.util.ArrayList;
4+
import java.util.Collections;
5+
import java.util.Comparator;
6+
import java.util.List;
7+
import java.util.Objects;
8+
import java.util.regex.Matcher;
9+
import java.util.regex.Pattern;
10+
import java.util.stream.Collectors;
11+
import lombok.experimental.UtilityClass;
12+
import org.springframework.lang.NonNull;
13+
14+
@UtilityClass
15+
public class AcceptLanguageParser {
16+
17+
public record Language(String code, String script, String region, double quality) {
18+
@Override
19+
public String toString() {
20+
return "Language{" +
21+
"code='" + code + '\'' +
22+
", script='" + script + '\'' +
23+
", region='" + region + '\'' +
24+
", quality=" + quality +
25+
'}';
26+
}
27+
}
28+
29+
private static final Pattern REGEX = Pattern.compile(
30+
"((([a-zA-Z]+(-[a-zA-Z0-9]+){0,2})|\\*)(;q=[0-1](\\.[0-9]+)?)?)*");
31+
32+
@NonNull
33+
public static List<Language> parseAcceptLanguage(String acceptLanguage) {
34+
if (acceptLanguage == null || acceptLanguage.isEmpty()) {
35+
return Collections.emptyList();
36+
}
37+
38+
List<Language> languages = new ArrayList<>();
39+
Matcher matcher = REGEX.matcher(acceptLanguage);
40+
41+
while (matcher.find()) {
42+
String match = matcher.group();
43+
if (match == null || match.isEmpty()) {
44+
continue;
45+
}
46+
47+
String[] parts = match.split(";");
48+
String ietfTag = parts[0];
49+
String[] ietfComponents = ietfTag.split("-");
50+
String code = ietfComponents[0];
51+
String script = ietfComponents.length == 3 ? ietfComponents[1] : null;
52+
String region = ietfComponents.length == 3 ? ietfComponents[2]
53+
: ietfComponents.length == 2 ? ietfComponents[1] : null;
54+
55+
double quality = 1.0;
56+
if (parts.length > 1 && parts[1].startsWith("q=")) {
57+
try {
58+
quality = Double.parseDouble(parts[1].substring(2));
59+
} catch (NumberFormatException e) {
60+
// ignore
61+
}
62+
}
63+
64+
languages.add(new Language(code, script, region, quality));
65+
}
66+
67+
return languages.stream()
68+
.filter(Objects::nonNull)
69+
.sorted(Comparator.comparingDouble((Language l) -> l.quality).reversed())
70+
.collect(Collectors.toList());
71+
}
72+
}

0 commit comments

Comments
 (0)