diff --git a/build.gradle b/build.gradle index d984bea..32ef14d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id "com.github.node-gradle.node" version "5.0.0" - id "run.halo.plugin.devtools" version "0.0.9" + id "run.halo.plugin.devtools" version "0.4.1" id "io.freefair.lombok" version "8.0.1" id 'java' } @@ -13,13 +13,15 @@ java { } repositories { + mavenLocal() maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots' } mavenCentral() } dependencies { - implementation platform('run.halo.tools.platform:plugin:2.17.0-SNAPSHOT') + implementation platform('run.halo.tools.platform:plugin:2.20.11') compileOnly 'run.halo.app:api' + compileOnly "run.halo.feed:api:0.0.2-SNAPSHOT" testImplementation 'run.halo.app:api' testImplementation 'org.springframework.boot:spring-boot-starter-test' @@ -54,5 +56,6 @@ build { } halo { - version = "2.17" + version = "2.20.11" + debug = true } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 62f495d..09523c0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/run/halo/umami/BasicProp.java b/src/main/java/run/halo/umami/BasicProp.java new file mode 100644 index 0000000..06857bb --- /dev/null +++ b/src/main/java/run/halo/umami/BasicProp.java @@ -0,0 +1,17 @@ +package run.halo.umami; + +import lombok.Data; +import reactor.core.publisher.Mono; +import run.halo.app.plugin.ReactiveSettingFetcher; + +@Data +public class BasicProp { + private String websiteId; + private String endpoint; + private String scriptName; + private String url; + + public static Mono fetch(ReactiveSettingFetcher settingFetcher) { + return settingFetcher.fetch("basic", BasicProp.class); + } +} diff --git a/src/main/java/run/halo/umami/RssTelemetryConfiguration.java b/src/main/java/run/halo/umami/RssTelemetryConfiguration.java new file mode 100644 index 0000000..f493dab --- /dev/null +++ b/src/main/java/run/halo/umami/RssTelemetryConfiguration.java @@ -0,0 +1,21 @@ +package run.halo.umami; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.plugin.ReactiveSettingFetcher; + +@Configuration +@ConditionalOnClass(name = "run.halo.feed.TelemetryRecorder") +@RequiredArgsConstructor +public class RssTelemetryConfiguration { + private final ExternalUrlSupplier externalUrlSupplier; + private final ReactiveSettingFetcher settingFetcher; + + @Bean + RssTelemetryUmamiRecorder rssTelemetryUmamiRecorder() { + return new RssTelemetryUmamiRecorder(settingFetcher, externalUrlSupplier); + } +} diff --git a/src/main/java/run/halo/umami/RssTelemetryUmamiRecorder.java b/src/main/java/run/halo/umami/RssTelemetryUmamiRecorder.java new file mode 100644 index 0000000..c52d1c7 --- /dev/null +++ b/src/main/java/run/halo/umami/RssTelemetryUmamiRecorder.java @@ -0,0 +1,122 @@ +package run.halo.umami; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.plugin.ReactiveSettingFetcher; +import run.halo.feed.TelemetryEventInfo; +import run.halo.feed.TelemetryRecorder; + +import java.net.URL; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@RequiredArgsConstructor +public class RssTelemetryUmamiRecorder implements TelemetryRecorder { + static final Map LANG_TO_COUNTRY = Map.of( + "zh", "CN", + "en", "US", + "ja", "JP", + "ko", "KR", + "fr", "FR", + "de", "DE", + "es", "ES", + "it", "IT", + "ru", "RU" + ); + private final WebClient webClient = WebClient.builder().build(); + private final ReactiveSettingFetcher settingFetcher; + private final ExternalUrlSupplier externalUrlSupplier; + + @Override + public void record(TelemetryEventInfo eventInfo) { + var propOpt = BasicProp.fetch(settingFetcher).blockOptional(); + if (propOpt.isEmpty()) { + return; + } + var siteUrl = propOpt.get().getEndpoint(); + var webSiteId = propOpt.get().getWebsiteId(); + if (StringUtils.isBlank(siteUrl) || StringUtils.isBlank(webSiteId)) { + return; + } + // https://umami.is/docs/api/sending-stats + webClient.post() + .uri(StringUtils.removeEnd(siteUrl, "/") + "/api/send") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.USER_AGENT, genUserAgent()) + .body(Mono.just(createBody(webSiteId, eventInfo)), Map.class) + .retrieve() + .bodyToMono(String.class) + .doOnError(e -> log.debug("Failed to send telemetry event to Umami.", e)) + .block(); + } + + private String genUserAgent() { + // umami has bot detection to avoid filtering out page views + return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/131.0.0.0 Safari/537.36"; + } + + private Map createBody(String webSiteId, TelemetryEventInfo eventInfo) { + var hostname = Optional.ofNullable(externalUrlSupplier.getRaw()) + .map(URL::getHost) + .orElse(StringUtils.EMPTY); + var payload = new HashMap<>(Map.of( + "hostname", hostname, + "language", convertToLanguageTag(eventInfo.getLanguage()), + "referrer", StringUtils.defaultString(eventInfo.getReferrer()), + "screen", StringUtils.defaultString(eventInfo.getScreen()), + "title", eventInfo.getTitle(), + "url", eventInfo.getPageUrl(), + "website", webSiteId, + "tag", "RSS Telemetry" + )); + if (StringUtils.isNotBlank(eventInfo.getIp())) { + payload.put("ip", eventInfo.getIp()); + } + return Map.of("payload", payload, "type", "event"); + } + + private static String convertToLanguageTag(String languageCode) { + try { + return convertToLanguageTagInternal(languageCode); + } catch (Throwable e) { + // ignore + return Locale.CHINA.toLanguageTag(); + } + } + + /** + *

Convert language code to language code with region.

+ * + * @param languageCode original code (such as zh or en) + * @return Standardized language code (such as zh-CN or en-US) + */ + private static String convertToLanguageTagInternal(String languageCode) { + if (languageCode == null || languageCode.isEmpty()) { + return Locale.CHINA.toLanguageTag(); + } + + Locale locale = Locale.forLanguageTag(languageCode); + + // if locale has no country, fill it with the default country + if (locale.getCountry().isEmpty()) { + var country = LANG_TO_COUNTRY.get(locale.getLanguage()); + if (country == null) { + return Locale.CHINA.toLanguageTag(); + } + return locale.toLanguageTag() + "-" + country; + } + + // Return the standardized language tag (such as zh-CN) + return locale.toLanguageTag(); + } +} diff --git a/src/main/java/run/halo/umami/UmamiTrackerProcessor.java b/src/main/java/run/halo/umami/UmamiTrackerProcessor.java index b50bfcd..fe6904a 100644 --- a/src/main/java/run/halo/umami/UmamiTrackerProcessor.java +++ b/src/main/java/run/halo/umami/UmamiTrackerProcessor.java @@ -1,6 +1,5 @@ package run.halo.umami; -import lombok.Data; import org.springframework.stereotype.Component; import org.thymeleaf.context.ITemplateContext; import org.thymeleaf.model.IModel; @@ -22,10 +21,10 @@ public UmamiTrackerProcessor(ReactiveSettingFetcher settingFetcher) { @Override public Mono process(ITemplateContext context, IModel model, IElementModelStructureHandler structureHandler) { - return settingFetcher.fetch("basic", BasicConfig.class) - .doOnNext(basicConfig -> { + return BasicProp.fetch(settingFetcher) + .doOnNext(prop -> { final IModelFactory modelFactory = context.getModelFactory(); - model.add(modelFactory.createText(trackerScript(basicConfig.getWebsiteId(), basicConfig.endpoint, basicConfig.scriptName))); + model.add(modelFactory.createText(trackerScript(prop.getWebsiteId(), prop.getEndpoint(), prop.getScriptName()))); }) .then(); } @@ -35,12 +34,4 @@ private String trackerScript(String websiteId, String endpoint, String scriptNam """.formatted(websiteId, endpoint, scriptName); } - - @Data - public static class BasicConfig { - String websiteId; - String endpoint; - String scriptName; - String url; - } } diff --git a/src/main/resources/ext-definitions.yaml b/src/main/resources/ext-definitions.yaml new file mode 100644 index 0000000..32d389f --- /dev/null +++ b/src/main/resources/ext-definitions.yaml @@ -0,0 +1,9 @@ +apiVersion: plugin.halo.run/v1alpha1 +kind: ExtensionDefinition +metadata: + name: feed-rss-telemetry-umami-recorder +spec: + className: run.halo.umami.RssTelemetryUmamiRecorder + extensionPointName: feed-telemetry-recorder + displayName: "Umami RSS 访问量记录器" + description: "将 RSS 的内容访问量上报到 Umami" diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index 923dd36..224abc3 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -7,7 +7,7 @@ metadata: spec: enabled: true version: 1.0.0 - requires: ">=2.17.0" + requires: ">=2.20.11" author: name: Halo website: https://github.com/halo-dev @@ -19,6 +19,9 @@ spec: issues: https://github.com/halo-sigs/plugin-umami/issues displayName: "Umami" description: "提供对 Umami 的集成" + pluginDependencies: + # TODO Update the version of PluginFeed + PluginFeed?: ">=1.1.0" license: - name: "GPL-3.0" url: "https://github.com/halo-sigs/plugin-umami/blob/main/LICENSE"