1.4.3原版

This commit is contained in:
anian
2026-03-15 23:33:02 +08:00
commit 54fb39d15c
135 changed files with 14156 additions and 0 deletions
@@ -0,0 +1,45 @@
package la.moony.friends;
import lombok.RequiredArgsConstructor;
import org.pf4j.PluginWrapper;
import org.springframework.stereotype.Component;
import org.springframework.util.PropertyPlaceholderHelper;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IModel;
import org.thymeleaf.model.IModelFactory;
import org.thymeleaf.processor.element.IElementModelStructureHandler;
import reactor.core.publisher.Mono;
import run.halo.app.theme.dialect.TemplateHeadProcessor;
import java.util.Properties;
@Component
@RequiredArgsConstructor
public class FriendsHeadProcessor implements TemplateHeadProcessor {
static final PropertyPlaceholderHelper PROPERTY_PLACEHOLDER_HELPER =
new PropertyPlaceholderHelper("${", "}");
private final PluginWrapper pluginWrapper;
@Override
public Mono<Void> process(ITemplateContext context, IModel model,
IElementModelStructureHandler structureHandler) {
final IModelFactory modelFactory = context.getModelFactory();
model.add(modelFactory.createText(friendsRssComponentScript()));
return Mono.empty();
}
private String friendsRssComponentScript() {
final Properties properties = new Properties();
properties.setProperty("version", pluginWrapper.getDescriptor().getVersion());
return PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders("""
<!-- plugin-friends-rss start -->
<script defer src="/plugins/plugin-friends/assets/static/friends-rss.iife.js?version=${version}"></script>
<link rel="stylesheet" href="/plugins/plugin-friends/assets/static/var.css?version=${version}" />
<!-- plugin-friends-rss end -->
""", properties);
}
}
@@ -0,0 +1,79 @@
package la.moony.friends;
import la.moony.friends.extension.CronFriendPost;
import la.moony.friends.extension.FriendPost;
import la.moony.friends.extension.RssFeedSyncLog;
import org.springframework.stereotype.Component;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.SchemeManager;
import run.halo.app.extension.index.IndexSpec;
import run.halo.app.plugin.BasePlugin;
import run.halo.app.plugin.PluginContext;
import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute;
@Component
public class FriendsPlugin extends BasePlugin {
private final SchemeManager schemeManager;
public FriendsPlugin(PluginContext pluginContext, SchemeManager schemeManager) {
super(pluginContext);
this.schemeManager = schemeManager;
}
@Override
public void start() {
schemeManager.register(FriendPost.class, indexSpecs -> {
indexSpecs.add(new IndexSpec()
.setName("spec.author")
.setIndexFunc(
simpleAttribute(FriendPost.class, friendPost -> friendPost.getSpec().getAuthor())));
indexSpecs.add(new IndexSpec()
.setName("spec.title")
.setIndexFunc(
simpleAttribute(FriendPost.class, friendPost -> friendPost.getSpec().getTitle())));
indexSpecs.add(new IndexSpec()
.setName("spec.description")
.setIndexFunc(
simpleAttribute(FriendPost.class, friendPost -> friendPost.getSpec().getDescription())));
indexSpecs.add(new IndexSpec()
.setName("spec.postLink")
.setIndexFunc(
simpleAttribute(FriendPost.class, friendPost -> friendPost.getSpec().getPostLink())));
indexSpecs.add(new IndexSpec()
.setName("spec.authorUrl")
.setIndexFunc(
simpleAttribute(FriendPost.class, friendPost -> friendPost.getSpec().getAuthorUrl())));
indexSpecs.add(new IndexSpec()
.setName("spec.linkName")
.setIndexFunc(
simpleAttribute(FriendPost.class, friendPost -> friendPost.getSpec().getLinkName())));
indexSpecs.add(new IndexSpec()
.setName("spec.pubDate")
.setIndexFunc(simpleAttribute(FriendPost.class, friendPost -> {
var pubDate = friendPost.getSpec().getPubDate();
return pubDate == null ? null : pubDate.toString();
}))
);
});
schemeManager.register(CronFriendPost.class);
schemeManager.register(RssFeedSyncLog.class, indexSpecs -> {
indexSpecs.add(new IndexSpec()
.setName("state")
.setIndexFunc(simpleAttribute(RssFeedSyncLog.class, rssFeedSyncLog -> {
var state = rssFeedSyncLog.getState();
return state == null ? null : state.name();
})));
});
}
@Override
public void stop() {
schemeManager.unregister(Scheme.buildFromType(FriendPost.class));
schemeManager.unregister(Scheme.buildFromType(CronFriendPost.class));
schemeManager.unregister(Scheme.buildFromType(RssFeedSyncLog.class));
}
}
@@ -0,0 +1,19 @@
package la.moony.friends;
import io.swagger.v3.oas.annotations.media.Schema;
import la.moony.friends.extension.Link;
import la.moony.friends.extension.RssFeedSyncLog;
import lombok.Data;
import lombok.experimental.Accessors;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
@Data
@Accessors(chain = true)
public class ListedRssSyncLog {
@Schema(requiredMode = REQUIRED)
private Link link;
private RssFeedSyncLog log;
}
@@ -0,0 +1,20 @@
package la.moony.friends;
import la.moony.friends.extension.Link;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
import java.util.List;
@Getter
public class RssFeedSyncEvent extends ApplicationEvent {
private final Link link;
private final int sum;
private final List<String> disableSyncList;
public RssFeedSyncEvent(Object source, Link link, int sum, List<String> disableSyncList) {
super(source);
this.link = link;
this.sum = sum;
this.disableSyncList = disableSyncList;
}
}
@@ -0,0 +1,170 @@
package la.moony.friends.endpoint;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import la.moony.friends.ListedRssSyncLog;
import la.moony.friends.extension.FriendPost;
import la.moony.friends.extension.RssFeedSyncLog;
import la.moony.friends.query.FriendPostQuery;
import la.moony.friends.query.RssSyncLogQuery;
import la.moony.friends.service.RssDetailService;
import la.moony.friends.service.RssFeedSyncLogService;
import la.moony.friends.vo.RssDetail;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.selector.FieldSelector;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
@Component
public class FriendEndpoint implements CustomEndpoint {
private final String tag = "api.friend.moony.la/v1alpha1/FriendPost";
private final ReactiveExtensionClient client;
private final RssDetailService rssDetailService;
private final RssFeedSyncLogService rssFeedSyncLogService;
public FriendEndpoint(ReactiveExtensionClient client, RssDetailService rssDetailService,
RssFeedSyncLogService rssFeedSyncLogService) {
this.client = client;
this.rssDetailService = rssDetailService;
this.rssFeedSyncLogService = rssFeedSyncLogService;
}
@Override
public RouterFunction<ServerResponse> endpoint() {
return SpringdocRouteBuilder.route()
.GET("friendposts", this::listFriendPost, builder -> {
builder.operationId("ListFriendPosts")
.description("List friendPost.")
.tag(tag)
.response(
responseBuilder()
.implementation(ListResult.generateGenericClass(FriendPost.class))
);
FriendPostQuery.buildParameters(builder);
})
.DELETE("friendposts/-/delete", this::deleteAllRssFeedSyncLog, builder -> {
builder.operationId("DeleteAllRssFeedSyncLog")
.description("Delete All RssFeedSyncLog.")
.tag(tag)
.response(responseBuilder()
.implementation(Void.class));
})
.GET("rsssynclogs", this::listRssSyncLog, builder -> {
builder.operationId("ListRssSyncLogs")
.description("List RssSyncLog.")
.tag(tag)
.response(
responseBuilder()
.implementation(ListResult.generateGenericClass(ListedRssSyncLog.class))
);
RssSyncLogQuery.buildParameters(builder);
})
.GET("parsingrss", this::parsingRss, builder -> {
builder.operationId("ParsingRss")
.tag(tag)
.parameter(parameterBuilder()
.name("rssUrl")
.in(ParameterIn.QUERY)
.required(true)
.implementation(String.class)
)
.parameter(parameterBuilder()
.name("fetchLimitNumber")
.in(ParameterIn.QUERY)
.required(false)
.implementation(String.class)
)
.response(
responseBuilder()
.implementation(RssDetail.class)
);
})
.POST("syncrssfeed/{name}", this::syncRssFeed,
builder -> builder.operationId("SyncRssFeed")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class))
.response(responseBuilder()
.implementation(Void.class))
)
.build();
}
Mono<ServerResponse> listFriendPost(ServerRequest request) {
FriendPostQuery friendPostQuery = new FriendPostQuery(request);
return client.listBy(FriendPost.class, friendPostQuery.toListOptions(),friendPostQuery.toPageRequest())
.flatMap(friendPosts -> ServerResponse.ok().bodyValue(friendPosts));
}
Mono<ServerResponse> deleteAllRssFeedSyncLog(ServerRequest request) {
return deleteAllRssFeedSyncLog()
.then(deleteAllFriendPost())
.then(ServerResponse.ok().build());
}
private Mono<Void> deleteAllRssFeedSyncLog() {
ListOptions listOptions = new ListOptions().setFieldSelector(
FieldSelector.of(isNull("metadata.deletionTimestamp")));
return client.listAll(RssFeedSyncLog.class, listOptions, Sort.unsorted())
.flatMap(rssFeedSyncLog -> client.delete(rssFeedSyncLog))
.then();
}
private Mono<Void> deleteAllFriendPost() {
ListOptions listOptions = new ListOptions().setFieldSelector(
FieldSelector.of(isNull("metadata.deletionTimestamp")));
return client.listAll(FriendPost.class, listOptions, Sort.unsorted())
.flatMap(friendPost -> client.delete(friendPost))
.then();
}
Mono<ServerResponse> listRssSyncLog(ServerRequest request) {
RssSyncLogQuery rssSyncLogQuery = new RssSyncLogQuery(request);
return rssFeedSyncLogService.listRssSyncLog(rssSyncLogQuery)
.flatMap(listRssSyncLogs -> ServerResponse.ok().bodyValue(listRssSyncLogs));
}
Mono<ServerResponse> parsingRss(ServerRequest request) {
final var rssUrl = request.queryParam("rssUrl").orElseThrow();
final var fetchLimitNumber = request.queryParam("fetchLimitNumber")
.map(Integer::parseInt)
.orElse(5);
return rssDetailService.fetchRssDetail(rssUrl,fetchLimitNumber)
.flatMap(rssDetail -> ServerResponse.ok().bodyValue(rssDetail));
}
private Mono<ServerResponse> syncRssFeed(ServerRequest request) {
var name = request.pathVariable("name");
return rssFeedSyncLogService.publishRssFeedSyncEvent(name)
.flatMap(rssFeedSyncEvent -> ServerResponse.ok().build());
}
@Override
public GroupVersion groupVersion() {
return GroupVersion.parseAPIVersion("api.friend.moony.la/v1alpha1");
}
}
@@ -0,0 +1,56 @@
package la.moony.friends.extension;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.List;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
@Data
@ToString
@EqualsAndHashCode(callSuper = true)
@GVK(
group = "friend.moony.la",
version = "v1alpha1",
kind = "CronFriendPost",
singular = "cronfriendpost",
plural = "cronfriendposts"
)
public class CronFriendPost extends AbstractExtension {
private CronSpec spec = new CronSpec();
private CronStatus status = new CronStatus();
@Data
public static class CronSpec {
private String cron;
private String timezone;
private boolean suspend;
@Schema(
minimum = "0"
)
private int successfulRetainLimit;
private List<String> disableSyncList;
}
@Data
public static class CronStatus {
private Instant lastScheduledTimestamp;
private Instant nextSchedulingTimestamp;
}
}
@@ -0,0 +1,43 @@
package la.moony.friends.extension;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
import java.time.Instant;
@Data
@ToString
@EqualsAndHashCode(callSuper = true)
@GVK(kind = "FriendPost", group = "friend.moony.la",
version = "v1alpha1", singular = "friendpost", plural = "friendposts")
public class FriendPost extends AbstractExtension {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private FriendPostSpec spec;
@Data
public static class FriendPostSpec {
private String author;
private String authorUrl;
private String logo;
private String title;
private String postLink;
private String description;
private Instant pubDate;
private String linkName;
}
}
@@ -0,0 +1,32 @@
package la.moony.friends.extension;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
@Data
@EqualsAndHashCode(callSuper = true)
@GVK(group = "core.halo.run", version = "v1alpha1",
kind = "Link", plural = "links", singular = "link")
public class Link extends AbstractExtension {
private LinkSpec spec;
@Data
public static class LinkSpec {
@Schema(required = true)
private String url;
@Schema(required = true)
private String displayName;
private String logo;
private String description;
private Integer priority;
private String groupName;
}
}
@@ -0,0 +1,31 @@
package la.moony.friends.extension;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.LinkedHashSet;
import lombok.Data;
import lombok.EqualsAndHashCode;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
@Data
@EqualsAndHashCode(callSuper = true)
@GVK(group = "core.halo.run", version = "v1alpha1", kind = "LinkGroup", plural = "linkgroups", singular = "linkgroup")
public class LinkGroup extends AbstractExtension {
private LinkGroupSpec spec;
@Data
public static class LinkGroupSpec {
@Schema(required = true)
private String displayName;
private Integer priority;
@Deprecated(since = "1.2.0", forRemoval = true)
@Schema(description = "Names of links below this group.")
@ArraySchema(arraySchema = @Schema(description = "Links of this group."), schema = @Schema(description = "Name of link."))
private LinkedHashSet<String> links;
}
}
@@ -0,0 +1,41 @@
package la.moony.friends.extension;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
import java.time.Instant;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
@Data
@EqualsAndHashCode(callSuper = true)
@GVK(kind = "RssFeedSyncLog", group = "friend.moony.la",
version = "v1alpha1", singular = "rssfeedsynclog", plural = "rssfeedsynclogs")
public class RssFeedSyncLog extends AbstractExtension {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String linkName;
@Schema(requiredMode = REQUIRED,defaultValue = "nolink")
private RssFeedSyncLogState state;
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private Instant syncTime;
private String failureReason;
private String failureMessage;
public enum RssFeedSyncLogState {
success,
failed,
onwait,
nolink;
private RssFeedSyncLogState() {
}
}
}
@@ -0,0 +1,28 @@
package la.moony.friends.finders;
import la.moony.friends.finders.impl.FriendFinderImpl;
import la.moony.friends.vo.FriendPostVo;
import la.moony.friends.vo.LinkGroupVo;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.extension.ListResult;
import java.util.Map;
import org.springframework.lang.Nullable;
public interface FriendFinder {
Flux<FriendPostVo> listAll();
Mono<ListResult<FriendPostVo>> list(@Nullable Integer page, @Nullable Integer size);
/**
* Lists friendPosts by query params.
*
* @param params query params see {@link FriendFinderImpl.FriendPostQuery}
*/
Mono<ListResult<FriendPostVo>> list(Map<String, Object> params);
Mono<FriendPostVo> getByName(String friendPostName);
Flux<LinkGroupVo> linkGroupBy();
}
@@ -0,0 +1,240 @@
package la.moony.friends.finders.impl;
import jakarta.annotation.Nonnull;
import la.moony.friends.extension.FriendPost;
import la.moony.friends.extension.Link;
import la.moony.friends.extension.LinkGroup;
import la.moony.friends.extension.RssFeedSyncLog;
import la.moony.friends.finders.FriendFinder;
import la.moony.friends.util.SortUtils;
import la.moony.friends.vo.FriendPostVo;
import la.moony.friends.vo.LinkGroupVo;
import la.moony.friends.vo.LinkVo;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Sort;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.lang.NonNull;
import run.halo.app.extension.*;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.theme.finders.Finder;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import static org.springframework.data.domain.Sort.Order.asc;
import static run.halo.app.extension.index.query.QueryFactory.*;
@Finder("friendFinder")
@RequiredArgsConstructor
public class FriendFinderImpl implements FriendFinder {
static final String UNGROUPED_NAME = "ungrouped";
private final ReactiveExtensionClient client;
@Override
public Flux<FriendPostVo> listAll() {
var listOptions = new ListOptions();
var query = all();
listOptions.setFieldSelector(FieldSelector.of(query));
return client.listAll(FriendPost.class, listOptions, defaultSort())
.flatMap(this::getFriendPostVo);
}
@Override
public Mono<ListResult<FriendPostVo>> list(Integer page, Integer size) {
return pageFriendPost(new ListOptions(), getPageRequest(page, size));
}
private PageRequestImpl getPageRequest(Integer page, Integer size) {
return PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size), defaultSort());
}
@Override
public Mono<ListResult<FriendPostVo>> list(Map<String, Object> params) {
var query = Optional.ofNullable(params)
.map(map -> JsonUtils.mapToObject(map, FriendPostQuery.class))
.orElseGet(FriendPostQuery::new);
return pageFriendPost(query.toListOptions(), query.toPageRequest());
}
@Override
public Mono<FriendPostVo> getByName(String friendPostName) {
return client.fetch(FriendPost.class, friendPostName)
.map(FriendPostVo::from);
}
@Override
public Flux<LinkGroupVo> linkGroupBy() {
return client.listAll(LinkGroup.class, new ListOptions(), defaultGroupSort())
.map(LinkGroupVo::from)
.concatMap(group -> linkListBy(group.getMetadata().getName())
.collectList()
.map(group::withLinks)
.defaultIfEmpty(group)
)
.mergeWith(Mono.defer(() -> linkListBy(UNGROUPED_NAME)
.collectList()
// do not return ungrouped group if no links
.filter(links -> !links.isEmpty())
.flatMap(links -> ungrouped()
.map(LinkGroupVo::from)
.map(group -> group.withLinks(links))
)
));
}
private Mono<ListResult<FriendPostVo>> pageFriendPost(ListOptions queryOptions, PageRequest page){
var listOptions = new ListOptions();
var query = all();
listOptions.setFieldSelector(FieldSelector.of(query));
var fieldSelector = queryOptions.getFieldSelector();
if (fieldSelector != null) {
listOptions.setFieldSelector(listOptions.getFieldSelector()
.andQuery(fieldSelector.query()));
}
return client.listBy(FriendPost.class, listOptions, page)
.flatMap(list -> Flux.fromStream(list.get())
.concatMap(this::getFriendPostVo)
.collectList()
.map(friendPostVos -> new ListResult<>(list.getPage(), list.getSize(),
list.getTotal(), friendPostVos)
)
)
.defaultIfEmpty(ListResult.emptyResult());
}
public Flux<LinkVo> linkListBy(String groupName) {
var listOptions = new ListOptions();
var query = isNull("metadata.deletionTimestamp");
if (UNGROUPED_NAME.equals(groupName)) {
query = and(query, isNull("spec.groupName"));
} else {
query = and(query, equal("spec.groupName", groupName));
}
listOptions.setFieldSelector(FieldSelector.of(query));
return client.listAll(Link.class, listOptions, defaultLinkSort())
.flatMapSequential(this::convertToListedVo)
.collectList()
.map(links -> {
// 排序逻辑
return links.stream()
.sorted(Comparator.comparing(link -> {
List<FriendPostVo> friendPosts = link.getFriendPosts();
if (friendPosts != null && !friendPosts.isEmpty()) {
return friendPosts.get(0).getSpec().getPubDate();
}
return null;
}, Comparator.nullsLast(Comparator.reverseOrder())
)).collect(Collectors.toList());
})
.flatMapMany(Flux::fromIterable);
}
public Mono<LinkVo> convertToListedVo(@NonNull Link link) {
Assert.notNull(link, "Link must not be null");
LinkVo linkVo = LinkVo.from(link);
linkVo.setFriendPosts(List.of());
return Mono.just(linkVo)
.flatMap(lp -> friendPostListBy(lp.getMetadata().getName())
.doOnNext(lp::setFriendPosts)
.thenReturn(lp)
).flatMap(p -> {
String linkName = p.getMetadata().getName();
return client.fetch(RssFeedSyncLog.class,"sync-log-" + linkName)
.doOnNext(p::setRssFeedSyncLog)
.thenReturn(p);
})
.defaultIfEmpty(linkVo);
}
public Mono<List<FriendPostVo>> friendPostListBy(String linkName) {
var listOptions = new ListOptions();
var query = isNull("metadata.deletionTimestamp");
query = and(query, equal("spec.linkName", linkName));
listOptions.setFieldSelector(FieldSelector.of(query));
var pageRequest = PageRequestImpl.of(pageNullSafe(1), sizeNullSafe(2), defaultSort());
return client.listBy(FriendPost.class, listOptions, pageRequest)
.flatMap(friendPosts -> Flux.fromStream(friendPosts.get())
.map(FriendPostVo::from).collectList());
}
Mono<LinkGroup> ungrouped() {
LinkGroup linkGroup = new LinkGroup();
linkGroup.setMetadata(new Metadata());
linkGroup.getMetadata().setName(UNGROUPED_NAME);
linkGroup.setSpec(new LinkGroup.LinkGroupSpec());
linkGroup.getSpec().setDisplayName("");
linkGroup.getSpec().setPriority(0);
return Mono.just(linkGroup);
}
static Sort defaultGroupSort() {
return Sort.by(asc("spec.priority"),
asc("metadata.creationTimestamp"),
asc("metadata.name")
);
}
static Sort defaultLinkSort() {
return Sort.by(asc("spec.priority"),
asc("metadata.creationTimestamp"),
asc("metadata.name")
);
}
static Sort defaultSort() {
return Sort.by("spec.pubDate").descending();
}
private Mono<FriendPostVo> getFriendPostVo(@Nonnull FriendPost friendPost) {
FriendPostVo friendPostVo = FriendPostVo.from(friendPost);
return Mono.just(friendPostVo);
}
static int pageNullSafe(Integer page) {
return ObjectUtils.defaultIfNull(page, 1);
}
static int sizeNullSafe(Integer size) {
return ObjectUtils.defaultIfNull(size, 10);
}
@Data
public static class FriendPostQuery {
private Integer page;
private Integer size;
private String linkName;
private List<String> sort;
public ListOptions toListOptions() {
var builder = ListOptions.builder();
if (StringUtils.isNotBlank(linkName)) {
builder.andQuery(equal("spec.linkName", linkName));
}
return builder.build();
}
public PageRequest toPageRequest() {
return PageRequestImpl.of(pageNullSafe(getPage()),
sizeNullSafe(getSize()), SortUtils.resolve(sort).and(defaultSort()));
}
}
}
@@ -0,0 +1,107 @@
package la.moony.friends.query;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import la.moony.friends.extension.FriendPost;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.core.fn.builders.operation.Builder;
import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable;
import org.springframework.web.reactive.function.server.ServerRequest;
import run.halo.app.core.extension.endpoint.SortResolver;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.router.IListRequest;
import run.halo.app.extension.router.SortableRequest;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import static java.util.Comparator.comparing;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springframework.data.domain.Sort.Order.desc;
import static run.halo.app.extension.index.query.QueryFactory.*;
import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter;
public class FriendPostQuery extends SortableRequest {
public FriendPostQuery(ServerRequest request) {
super(request.exchange());
}
@Nullable
public String getKeyword() {
return StringUtils.defaultIfBlank(queryParams.getFirst("keyword"), null);
}
@Nullable
public String getLinkName() {
return queryParams.getFirst("linkName");
}
public ListOptions toListOptions() {
var builder = ListOptions.builder(super.toListOptions());
Optional.ofNullable(getKeyword())
.filter(StringUtils::isNotBlank)
.ifPresent(keyword -> builder.andQuery(or(
contains("spec.author", keyword),
contains("spec.authorUrl", keyword),
contains("spec.title", keyword)
)));
Optional.ofNullable(getLinkName())
.filter(StringUtils::isNotBlank)
.ifPresent(linkName -> builder.andQuery(equal("spec.linkName", linkName)));
return builder.build();
}
public Comparator<FriendPost> toComparator() {
List<Comparator<FriendPost>> comparators = new ArrayList<>();
var sort = getSort();
var ctOrder = sort.getOrderFor("pubDate");
if (ctOrder != null) {
Comparator<FriendPost> comparator =
comparing(friendPost -> friendPost.getSpec().getPubDate());
if (ctOrder.isDescending()) {
comparator = comparator.reversed();
}
comparators.add(comparator);
}
Comparator<FriendPost> comparator =
comparing(friendPost -> friendPost.getSpec().getPubDate());
comparators.add(comparator.reversed());
return comparators.stream()
.reduce(Comparator::thenComparing)
.orElse(null);
}
public PageRequest toPageRequest() {
var sort = SortResolver.defaultInstance.resolve(exchange).and(defaultSort());
return PageRequestImpl.of(getPage(), getSize(), sort);
}
public static Sort defaultSort() {
return Sort.by(desc("spec.pubDate"));
}
public static void buildParameters(Builder builder) {
IListRequest.buildParameters(builder);
builder.parameter(sortParameter())
.parameter(parameterBuilder()
.in(ParameterIn.QUERY)
.name("linkName")
.description("CronFriendPost filtered by linkName.")
.implementation(String.class)
.required(false))
.parameter(parameterBuilder()
.in(ParameterIn.QUERY)
.name("keyword")
.description("CronFriendPost filtered by keyword.")
.implementation(String.class)
.required(false));
}
}
@@ -0,0 +1,60 @@
package la.moony.friends.query;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.core.fn.builders.operation.Builder;
import org.springframework.lang.Nullable;
import org.springframework.web.reactive.function.server.ServerRequest;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.router.IListRequest;
import run.halo.app.extension.router.SortableRequest;
import java.util.Optional;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static run.halo.app.extension.index.query.QueryFactory.*;
import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter;
public class RssSyncLogQuery extends SortableRequest {
public RssSyncLogQuery(ServerRequest request) {
super(request.exchange());
}
@Nullable
public String getKeyword() {
return StringUtils.defaultIfBlank(queryParams.getFirst("keyword"), null);
}
@Nullable
public String getState() {
return queryParams.getFirst("state");
}
public ListOptions toListOptions() {
var builder = ListOptions.builder(super.toListOptions());
Optional.ofNullable(getKeyword())
.filter(StringUtils::isNotBlank)
.ifPresent(keyword -> builder.andQuery(or(
contains("metadata.name", keyword),
contains("spec.displayName", keyword)
)));
return builder.build();
}
public static void buildParameters(Builder builder) {
IListRequest.buildParameters(builder);
builder.parameter(sortParameter())
.parameter(parameterBuilder()
.in(ParameterIn.QUERY)
.name("keyword")
.implementation(String.class)
.required(false))
.parameter(parameterBuilder()
.in(ParameterIn.QUERY)
.name("state")
.implementation(String.class)
.required(false));
}
}
@@ -0,0 +1,121 @@
package la.moony.friends.reconciler;
import la.moony.friends.extension.CronFriendPost;
import la.moony.friends.service.RssFeedSyncLogService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.convert.ApplicationConversionService;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.stereotype.Component;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import java.time.*;
import java.util.Objects;
@Component
public class CronCronFriendPostReconciler implements Reconciler<Reconciler.Request>{
private static final Logger log = LoggerFactory.getLogger(CronCronFriendPostReconciler.class);
private final ExtensionClient client;
private Clock clock;
private final RssFeedSyncLogService rssFeedSyncLogService;
public CronCronFriendPostReconciler(ExtensionClient client,RssFeedSyncLogService rssFeedSyncLogService) {
this.client = client;
this.rssFeedSyncLogService = rssFeedSyncLogService;
this.clock = Clock.systemDefaultZone();
}
void setClock(Clock clock) {
this.clock = clock;
}
public Result reconcile(Request request) {
return (Result)this.client.fetch(CronFriendPost.class, request.name()).map((cronFriendPost) -> {
if (ExtensionUtil.isDeleted(cronFriendPost)) {
return Result.doNotRetry();
} else {
CronFriendPost.CronSpec spec = cronFriendPost.getSpec();
if (!spec.isSuspend()) {
return Result.doNotRetry();
} else {
String cron = spec.getCron();
String timezone = spec.getTimezone();
ZoneId zoneId = ZoneId.systemDefault();
if (timezone != null) {
try {
zoneId = ApplicationConversionService.getSharedInstance().convert(timezone, ZoneId.class);
} catch (DateTimeException dateTimeException) {
log.error("Invalid zone ID {}", timezone, dateTimeException);
return Result.doNotRetry();
}
}
Instant now = Instant.now(this.clock);
if (!CronExpression.isValidExpression(cron)) {
log.error("Cron expression {} is invalid.", cron);
return Result.doNotRetry();
} else {
CronExpression cronExp = CronExpression.parse(cron);
CronFriendPost.CronStatus status = cronFriendPost.getStatus();
Instant lastScheduledTimestamp = status.getLastScheduledTimestamp();
if (lastScheduledTimestamp == null) {
lastScheduledTimestamp = cronFriendPost.getMetadata().getCreationTimestamp();
}
ZonedDateTime nextFromNow = cronExp.next(now.atZone(zoneId));
ZonedDateTime nextFromLast = cronExp.next(lastScheduledTimestamp.atZone(zoneId));
if (nextFromNow != null && nextFromLast != null) {
if (Objects.equals(nextFromNow, nextFromLast)) {
log.info("Skip scheduling and next scheduled at {}", nextFromNow);
status.setNextSchedulingTimestamp(nextFromNow.toInstant());
this.client.update(cronFriendPost);
return new Result(true, Duration.between(now, nextFromNow));
} else {
rssFeedSyncLogService.publishRssFeedSyncEvent("all").block();
ZonedDateTime zonedNow = now.atZone(zoneId);
ZonedDateTime scheduleTimestamp = now.atZone(zoneId);
ZonedDateTime next;
for(next = lastScheduledTimestamp.atZone(zoneId); next != null && next.isBefore(zonedNow); next = cronExp.next(next)) {
scheduleTimestamp = next;
}
status.setLastScheduledTimestamp(scheduleTimestamp.toInstant());
if (next != null) {
status.setNextSchedulingTimestamp(next.toInstant());
}
this.client.update(cronFriendPost);
log.info("Scheduled at {} and next scheduled at {}", scheduleTimestamp, next);
return new Result(true, Duration.between(now, next));
}
} else {
return Result.doNotRetry();
}
}
}
}
}).orElseGet(Result::doNotRetry);
}
public Controller setupWith(ControllerBuilder builder) {
return builder.extension(new CronFriendPost()).workerCount(1).build();
}
}
@@ -0,0 +1,47 @@
package la.moony.friends.reconciler;
import la.moony.friends.extension.FriendPost;
import la.moony.friends.extension.Link;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.router.selector.FieldSelector;
import static run.halo.app.extension.index.query.QueryFactory.equal;
@Component
@RequiredArgsConstructor
public class LinkReconciler implements Reconciler<Reconciler.Request> {
private final ExtensionClient client;
@Override
public Result reconcile(Request request) {
client.fetch(Link.class, request.name())
.ifPresent(link -> {
if (ExtensionUtil.isDeleted(link)) {
var friendPostListOptions = new ListOptions();
friendPostListOptions.setFieldSelector(FieldSelector.of(
equal("spec.linkName",link.getMetadata().getName())
));
client.listAll(FriendPost.class,friendPostListOptions, Sort.by("metadata.creationTimestamp").descending())
.forEach(friendPost -> client.delete(friendPost));
}
});
return Result.doNotRetry();
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new Link())
.build();
}
}
@@ -0,0 +1,300 @@
package la.moony.friends.reconciler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rometools.rome.feed.synd.SyndContent;
import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.SyndFeed;
import com.rometools.rome.io.SyndFeedInput;
import com.rometools.rome.io.XmlReader;
import la.moony.friends.RssFeedSyncEvent;
import la.moony.friends.extension.CronFriendPost;
import la.moony.friends.extension.FriendPost;
import la.moony.friends.extension.Link;
import la.moony.friends.extension.RssFeedSyncLog;
import la.moony.friends.util.CommonUtils;
import la.moony.friends.util.LinkRequest;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.event.EventListener;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.Unstructured;
import run.halo.app.extension.controller.*;
import run.halo.app.extension.router.selector.FieldSelector;
import java.net.URL;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.springframework.data.domain.Sort.Order.asc;
import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations;
import static run.halo.app.extension.index.query.QueryFactory.and;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
@Component
public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,SmartLifecycle {
private static final Logger log = LoggerFactory.getLogger(RssSyncReconciler.class);
private volatile boolean running = false;
private final ExtensionClient client;
private final RequestQueue<Request> queue;
private final Controller controller;
private final ObjectMapper objectMapper = Unstructured.OBJECT_MAPPER;
public RssSyncReconciler(ExtensionClient client) {
this.client = client;
queue = new DefaultQueue<>(Instant::now);
controller = this.setupWith(null);
}
@Override
public Result reconcile(Request request) {
Link link = request.link();
int sum = request.sum();
List<String> disableSyncList = request.disableSyncList();
var linkName = link.getMetadata().getName();
String rssUrl = getRss(link);
boolean isContains = false;
if (ObjectUtils.isNotEmpty(disableSyncList)) {
isContains = disableSyncList.contains(linkName);
}
String syncLogName = "sync-log-" + linkName;
RssFeedSyncLog newRssFeedSyncLog = new RssFeedSyncLog();
Metadata metadata = new Metadata();
metadata.setName(syncLogName);
newRssFeedSyncLog.setMetadata(metadata);
newRssFeedSyncLog.setLinkName(linkName);
if (StringUtils.isNotEmpty(rssUrl) && !isContains) {
tryToSynchronizeFriendPost(newRssFeedSyncLog, link, sum);
}else if (StringUtils.isEmpty(rssUrl)) {
newRssFeedSyncLog.setSyncTime(Instant.now());
newRssFeedSyncLog.setState(RssFeedSyncLog.RssFeedSyncLogState.nolink);
newRssFeedSyncLog.setFailureReason("No RSS link");
newRssFeedSyncLog.setFailureMessage("无 RSS 链接.");
}else if (isContains) {
newRssFeedSyncLog.setSyncTime(Instant.now());
newRssFeedSyncLog.setState(RssFeedSyncLog.RssFeedSyncLogState.failed);
newRssFeedSyncLog.setFailureReason("admin disables sync");
newRssFeedSyncLog.setFailureMessage("管理员禁用同步.");
}
Optional<RssFeedSyncLog> fetch = client.fetch(RssFeedSyncLog.class, syncLogName);
if (fetch.isPresent()) {
RssFeedSyncLog rssFeedSyncLog = fetch.get();
rssFeedSyncLog.setState(newRssFeedSyncLog.getState());
rssFeedSyncLog.setSyncTime(newRssFeedSyncLog.getSyncTime());
rssFeedSyncLog.setFailureReason(newRssFeedSyncLog.getFailureReason());
rssFeedSyncLog.setFailureMessage(newRssFeedSyncLog.getFailureMessage());
client.update(rssFeedSyncLog);
}else {
client.create(newRssFeedSyncLog);
}
return Result.doNotRetry();
}
public String getRss(Link link) {
var annotations = nullSafeAnnotations(link);
//处理旧数据 rss_uri
String rssUri = annotations.get("rss_uri");
String rssUrl = annotations.get("rss_url");
String isRequest = annotations.get("is_request");
if ((StringUtils.isEmpty(rssUrl) && StringUtils.isEmpty(isRequest)) || StringUtils.isNotEmpty(rssUri)) {
String linkRss = LinkRequest.getLinkRss(link.getSpec().getUrl());
if (StringUtils.isNotEmpty(rssUri)) {
linkRss = rssUri;
annotations.remove("rss_uri");
annotations.put("rss_url", rssUri);
}else {
annotations.put("is_request","true");
if (StringUtils.isNotEmpty(linkRss)) {
annotations.put("rss_url",linkRss);
}
}
updateLink(link);
return linkRss;
}
return rssUrl;
}
public void updateLink(Link link) {
Map extensionMap = objectMapper.convertValue(link, Map.class);
var extension = new Unstructured(extensionMap);
client.update(extension);
}
private void tryToSynchronizeFriendPost(RssFeedSyncLog syncLog, Link link, int sum) {
var linkName = link.getMetadata().getName();
var annotations = nullSafeAnnotations(link);
var rssUrl = annotations.get("rss_url");
CronFriendPost cron = new CronFriendPost();
var spec = new CronFriendPost.CronSpec();
spec.setSuccessfulRetainLimit(5);
cron.setSpec(spec);
syncLog.setSyncTime(Instant.now());
List<FriendPost> friendPostList = fetchFriendPost(rssUrl, sum, syncLog);
// 即使获取失败也继续处理
if (friendPostList != null && !friendPostList.isEmpty()) {
for (FriendPost rssPost : friendPostList) {
var friendPostListOptions = new ListOptions();
friendPostListOptions.setFieldSelector(FieldSelector.of(
and(isNull("metadata.deletionTimestamp"),
equal("spec.postLink",rssPost.getSpec().getPostLink())
)
));
long total = client.listBy(FriendPost.class, friendPostListOptions,
PageRequestImpl.ofSize(1)).getTotal();
if (total==0) {
FriendPost friendPost = new FriendPost();
// 设置元数据才能保存
friendPost.setMetadata(new Metadata());
friendPost.getMetadata().setGenerateName("friend-post-");
friendPost.setSpec(rssPost.getSpec());
var friendPostSpec = friendPost.getSpec();
friendPostSpec.setLogo(link.getSpec().getLogo());
friendPostSpec.setAuthor(link.getSpec().getDisplayName());
friendPostSpec.setAuthorUrl(link.getSpec().getUrl());
friendPostSpec.setLinkName(linkName);
String description = friendPost.getSpec().getDescription();
//解析html内容转换成文本
if (StringUtils.isNotEmpty(description)){
description = CommonUtils.parseAndTruncateHtml2Text(description, 200);
String regexp = "[ *|\\s*]*";
description = description.replaceFirst(regexp, "").trim();
friendPostSpec.setDescription(description);
}
client.create(friendPost);
}
}
delFriendPost(linkName,sum);
}
}
public List<FriendPost> fetchFriendPost(String rssAddress,int postsLimit, RssFeedSyncLog sync) {
List<FriendPost> friendPostList = new ArrayList<>();
try {
SyndFeed feed = new SyndFeedInput().build(new XmlReader(new URL(rssAddress)));
int postCount = 0;
for (SyndEntry entry : feed.getEntries()) {
Date date = entry.getPublishedDate();
if (date == null) {
date = entry.getUpdatedDate();
}
String description = "";
SyndContent content = entry.getDescription();
if (content != null) {
description = content.getValue();
}else {
List<SyndContent> contents = entry.getContents();
if (!contents.isEmpty()) {
description = contents.get(0).getValue();
}
}
FriendPost friendPost = new FriendPost();
friendPost.setSpec(new FriendPost.FriendPostSpec());
friendPost.getSpec().setTitle(entry.getTitle());
friendPost.getSpec().setPostLink(entry.getLink());
friendPost.getSpec().setPubDate(date.toInstant());
friendPost.getSpec().setDescription(description);
friendPostList.add(friendPost);
postCount++;
if (postCount >= postsLimit) {
break;
}
}
sync.setState(RssFeedSyncLog.RssFeedSyncLogState.success);
} catch (Exception e) {
sync.setState(RssFeedSyncLog.RssFeedSyncLogState.failed);
sync.setFailureReason("CrawlingRSSError");
sync.setFailureMessage(e.getMessage());
log.error("error in crawling rss", e);
return null;
}
return friendPostList;
}
public void delFriendPost(String linkName,Integer sum) {
var friendPostListOptions = new ListOptions();
friendPostListOptions.setFieldSelector(FieldSelector.of(
equal("spec.linkName",linkName)
));
long total = client.listBy(FriendPost.class, friendPostListOptions, PageRequestImpl.ofSize(1)).getTotal();
if (total > sum) {
int l = (int) (total - sum);
PageRequestImpl pageRequestImpl = PageRequestImpl.of(pageNullSafe(1), sizeNullSafe(l),
Sort.by(asc("spec.pubDate")));
ListResult<FriendPost> friendPosts =
client.listBy(FriendPost.class, friendPostListOptions, pageRequestImpl);
for (FriendPost friendPost : friendPosts) {
client.delete(friendPost);
}
}
}
static int pageNullSafe(Integer page) {
return ObjectUtils.defaultIfNull(page, 1);
}
static int sizeNullSafe(Integer size) {
return ObjectUtils.defaultIfNull(size, 10);
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return new DefaultController<>(
this.getClass().getName(),
this,
queue,
null,
Duration.ofMillis(300),
Duration.ofMinutes(5),
5);
}
@Override
public void start() {
this.controller.start();
this.running = true;
}
@Override
public void stop() {
this.running = false;
this.controller.dispose();
}
@Override
public boolean isRunning() {
return this.running;
}
@EventListener(RssFeedSyncEvent.class)
public void onReplyEvent(RssFeedSyncEvent event) {
var request = new Request(event.getLink(),event.getSum(),event.getDisableSyncList());
queue.addImmediately(request);
}
public record Request(Link link,int sum,List<String> disableSyncList) {
}
}
@@ -0,0 +1,94 @@
package la.moony.friends.rest;
import la.moony.friends.finders.FriendFinder;
import la.moony.friends.vo.FriendPostVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.plugin.ReactiveSettingFetcher;
import run.halo.app.theme.TemplateNameResolver;
import run.halo.app.theme.router.ModelConst;
import run.halo.app.theme.router.PageUrlUtils;
import run.halo.app.theme.router.UrlContextListResult;
import java.util.HashMap;
import java.util.Map;
import static run.halo.app.theme.router.PageUrlUtils.totalPage;
@Configuration
@RequiredArgsConstructor
@Slf4j
public class FriendRouter {
private final FriendFinder friendFinder;
private final TemplateNameResolver templateNameResolver;
private final ReactiveSettingFetcher settingFetcher;
@Bean
RouterFunction<ServerResponse> friendTemplateRoute() {
return RouterFunctions.route().GET("/friends",this::handlerFunction)
.GET("/friends/page/{page:\\d+}",this::handlerFunction)
.build();
}
private Mono<ServerResponse> handlerFunction(ServerRequest request) {
return templateNameResolver.resolveTemplateNameOrDefault(request.exchange(), "friends")
.flatMap( templateName -> ServerResponse.ok().render(templateName,
java.util.Map.of(ModelConst.TEMPLATE_ID, templateName,
"friends", friendPostList(request),
"title",getFriendTitle())
));
}
Mono<String> getFriendTitle() {
return this.settingFetcher.get("base").map(
setting -> setting.get("title").asText("友链朋友圈")).defaultIfEmpty(
"友链朋友圈");
}
private Mono<UrlContextListResult<FriendPostVo>> friendPostList(ServerRequest request) {
String path = request.path();
int pageNum = pageNumInPathVariable(request);
String linkName = request.queryParam("linkName")
.filter(StringUtils::isNotBlank)
.orElse(null);
return this.settingFetcher.get("base")
.map(item -> item.get("pageSize").asInt(10))
.defaultIfEmpty(10)
.flatMap(pageSize -> {
Map<String, Object> params = new HashMap<>();
params.put("page",pageNum);
params.put("size",pageSize);
if(StringUtils.isNotBlank(linkName)){
params.put("linkName",linkName);
}
return friendFinder.list(params)
.map(list -> new UrlContextListResult.Builder<FriendPostVo>()
.listResult(list)
.nextUrl(PageUrlUtils.nextPageUrl(path, totalPage(list)))
.prevUrl(PageUrlUtils.prevPageUrl(path))
.build()
);
});
}
private int pageNumInPathVariable(ServerRequest request) {
String page = request.pathVariables().get("page");
return NumberUtils.toInt(page, 1);
}
}
@@ -0,0 +1,9 @@
package la.moony.friends.service;
import la.moony.friends.vo.RssDetail;
import reactor.core.publisher.Mono;
public interface RssDetailService {
Mono<RssDetail> fetchRssDetail(String rssUrl, int fetchLimitNumber);
}
@@ -0,0 +1,13 @@
package la.moony.friends.service;
import la.moony.friends.ListedRssSyncLog;
import la.moony.friends.query.RssSyncLogQuery;
import reactor.core.publisher.Mono;
import run.halo.app.extension.ListResult;
public interface RssFeedSyncLogService {
Mono<ListResult<ListedRssSyncLog>> listRssSyncLog(RssSyncLogQuery query);
Mono<Void> publishRssFeedSyncEvent(String name);
}
@@ -0,0 +1,53 @@
package la.moony.friends.service.impl;
import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.SyndFeed;
import com.rometools.rome.io.SyndFeedInput;
import com.rometools.rome.io.XmlReader;
import la.moony.friends.service.RssDetailService;
import la.moony.friends.vo.RssDetail;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
@Component
public class RssDetailServiceImpl implements RssDetailService {
@Override
public Mono<RssDetail> fetchRssDetail(String rssUrl, int fetchLimitNumber) {
RssDetail rssDetail = new RssDetail();
try {
// 创建 SyndFeedInput 对象并从 URL 获取 RSS 提要
SyndFeedInput input = new SyndFeedInput();
SyndFeed feed = input.build(new XmlReader(new URL(rssUrl)));
RssDetail.Channel channel = new RssDetail.Channel();
channel.setTitle(feed.getTitle());
channel.setLink(feed.getLink());
channel.setDescription(feed.getDescription());
rssDetail.setChannel(channel);
List<RssDetail.Item> items = new ArrayList<>();
int count = 0;
for (SyndEntry syndEntry : feed.getEntries()) {
RssDetail.Item item = new RssDetail.Item();
item.setTitle(syndEntry.getTitle());
item.setLink(syndEntry.getLink());
item.setAuthor(syndEntry.getAuthor());
item.setDescription(syndEntry.getDescription().getValue());
item.setPubDate(syndEntry.getPublishedDate().toInstant());
items.add(item);
count++;
if (count >= fetchLimitNumber) {
break;
}
}
channel.setItems(items);
}catch (Exception e) {
e.printStackTrace();
}
return Mono.just(rssDetail);
}
}
@@ -0,0 +1,97 @@
package la.moony.friends.service.impl;
import la.moony.friends.ListedRssSyncLog;
import la.moony.friends.RssFeedSyncEvent;
import la.moony.friends.extension.CronFriendPost;
import la.moony.friends.extension.Link;
import la.moony.friends.extension.RssFeedSyncLog;
import la.moony.friends.query.RssSyncLogQuery;
import la.moony.friends.service.RssFeedSyncLogService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.selector.FieldSelector;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
@Component
public class RssFeedSyncLogServiceImpl implements RssFeedSyncLogService {
private final ReactiveExtensionClient client;
private final ApplicationEventPublisher eventPublisher;
public RssFeedSyncLogServiceImpl(ReactiveExtensionClient client,
ApplicationEventPublisher eventPublisher) {
this.client = client;
this.eventPublisher = eventPublisher;
}
@Override
public Mono<ListResult<ListedRssSyncLog>> listRssSyncLog(RssSyncLogQuery query) {
return buildListOptions(query)
.flatMap(listOptions -> client.listBy(Link.class, listOptions,
PageRequestImpl.of(query.getPage(), query.getSize(), query.getSort())
))
.flatMap(listResult -> Flux.fromStream(listResult.get())
.map(this::getListedRssSyncLog)
.flatMapSequential(Function.identity())
.collectList()
.map(listedApplications -> new ListResult<>(listResult.getPage(), listResult.getSize(),
listResult.getTotal(), listedApplications)
)
.defaultIfEmpty(ListResult.emptyResult())
);
}
private Mono<ListedRssSyncLog> getListedRssSyncLog(Link link) {
Assert.notNull(link, "The link must not be null.");
var listedRssSyncLog = new ListedRssSyncLog().setLink(link);
var logMono = client.fetch(RssFeedSyncLog.class,"sync-log-"+link.getMetadata().getName())
.doOnNext(listedRssSyncLog::setLog);
return Mono.when(logMono)
.thenReturn(listedRssSyncLog);
}
Mono<ListOptions> buildListOptions(RssSyncLogQuery query) {
return Mono.just(query.toListOptions());
}
public Mono<Void> publishRssFeedSyncEvent(String name) {
CronFriendPost cron = new CronFriendPost();
CronFriendPost.CronSpec cronSpec = new CronFriendPost.CronSpec();
cronSpec.setSuccessfulRetainLimit(5);
cronSpec.setDisableSyncList(new ArrayList<>());
cron.setSpec(cronSpec);
return client.fetch(CronFriendPost.class, "cron-friends-default")
.defaultIfEmpty(cron)
.flatMap(cronFriendPost -> {
var spec = cronFriendPost.getSpec();
int successfulRetainLimit = spec.getSuccessfulRetainLimit();
List<String> disableSyncList = spec.getDisableSyncList();
int sum = successfulRetainLimit == 0 ? 5 : successfulRetainLimit;
var listOptions = new ListOptions();
FieldSelector fieldSelector = FieldSelector.of(isNull("metadata.deletionTimestamp"));
if (!StringUtils.equals(name,"all")) {
fieldSelector = fieldSelector.andQuery(equal("metadata.name",name));
}
listOptions.setFieldSelector(fieldSelector);
return client.listAll(Link.class, listOptions, Sort.by("metadata.creationTimestamp"))
.doOnNext(link -> eventPublisher.publishEvent(new RssFeedSyncEvent(this, link,sum,disableSyncList)))
.then();
});
}
}
@@ -0,0 +1,24 @@
package la.moony.friends.util;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
public class CommonUtils {
public static String parseAndTruncateHtml2Text(String html, int length) {
if (StringUtils.isBlank(html)) {
return "";
}
String text = Jsoup.parse(html).text();
if (text.length() <= length) {
return text;
} else {
text = text.substring(0, length);
text += "...";
}
return text;
}
}
@@ -0,0 +1,53 @@
package la.moony.friends.util;
import java.io.IOException;
import java.util.Map;
import java.util.Objects;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LinkRequest {
private static final Logger log = LoggerFactory.getLogger(LinkRequest.class);
private final static String USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/58.0.3029.110 Safari/537.3";
public static String getLinkRss(String linkUrl) {
Document document = null;
try {
document = Jsoup.connect(linkUrl)
.followRedirects(true)
.maxBodySize(1024 * 1024 * 20)
.timeout(10000)
.headers(
Map.of(
"User-Agent", USER_AGENT,
"Referer", linkUrl,
"Accept", "text/html,application/xhtml+xml,application/xml"
)
)
.get();
} catch (IOException e) {
log.error("Failed to get link rss ",e);
}
if(document != null) {
Element rssElement = document.selectFirst("link[type=application/rss+xml]");
if (Objects.nonNull(rssElement)) {
return rssElement.absUrl("href");
}
Element atomElement = document.selectFirst("link[type=application/atom+xml]");
if (Objects.nonNull(atomElement)) {
return atomElement.absUrl("href");
}
}
return "";
}
}
@@ -0,0 +1,43 @@
package la.moony.friends.util;
import java.util.List;
import lombok.experimental.UtilityClass;
import org.springframework.data.domain.Sort;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
@UtilityClass
public class SortUtils {
static final String delimiter = ",";
/**
* <p>Resolve from direction params, e.g. "name,asc" or "name"</p>
*
* @param directionParams direction params
* @return sort object
*/
public static Sort resolve(List<String> directionParams) {
if (CollectionUtils.isEmpty(directionParams)) {
return Sort.unsorted();
}
Sort.Order[] orders = new Sort.Order[directionParams.size()];
for (int i = 0; i < directionParams.size(); i++) {
String[] parts = directionParams.get(i).split(delimiter);
if (parts.length == 1) {
orders[i] = new Sort.Order(Sort.Direction.ASC, parts[0]);
} else {
orders[i] = new Sort.Order(toDirection(parts[1]), parts[0]);
}
}
return Sort.by(orders);
}
private static Sort.Direction toDirection(@NonNull String direction) {
Assert.notNull(direction, "Direction must not be null");
if (direction.contains(" ")) {
throw new IllegalArgumentException("Direction must not contain whitespace");
}
return Sort.Direction.fromString(direction);
}
}
@@ -0,0 +1,27 @@
package la.moony.friends.vo;
import la.moony.friends.extension.FriendPost;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
import run.halo.app.extension.MetadataOperator;
@Data
@SuperBuilder
@ToString
@EqualsAndHashCode
public class FriendPostVo {
private MetadataOperator metadata;
private FriendPost.FriendPostSpec spec;
public static FriendPostVo from(FriendPost friendPost) {
return FriendPostVo.builder()
.metadata(friendPost.getMetadata())
.spec(friendPost.getSpec())
.build();
}
}
@@ -0,0 +1,31 @@
package la.moony.friends.vo;
import la.moony.friends.extension.LinkGroup;
import lombok.Builder;
import lombok.Value;
import lombok.With;
import run.halo.app.extension.MetadataOperator;
import run.halo.app.theme.finders.vo.ExtensionVoOperator;
import java.util.List;
@Value
@Builder
public class LinkGroupVo implements ExtensionVoOperator {
MetadataOperator metadata;
LinkGroup.LinkGroupSpec spec;
@With
List<LinkVo> links;
public static LinkGroupVo from(LinkGroup linkGroup) {
return LinkGroupVo.builder()
.metadata(linkGroup.getMetadata())
.spec(linkGroup.getSpec())
.links(List.of())
.build();
}
}
@@ -0,0 +1,38 @@
package la.moony.friends.vo;
import la.moony.friends.extension.Link;
import la.moony.friends.extension.RssFeedSyncLog;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import lombok.Value;
import lombok.With;
import lombok.experimental.SuperBuilder;
import run.halo.app.extension.MetadataOperator;
import run.halo.app.theme.finders.vo.ExtensionVoOperator;
import java.util.List;
@Data
@SuperBuilder
@ToString
@EqualsAndHashCode
public class LinkVo implements ExtensionVoOperator {
MetadataOperator metadata;
Link.LinkSpec spec;
RssFeedSyncLog rssFeedSyncLog;
List<FriendPostVo> friendPosts;
public static LinkVo from(Link link) {
return LinkVo.builder()
.metadata(link.getMetadata())
.spec(link.getSpec())
.friendPosts(List.of())
.build();
}
}
@@ -0,0 +1,41 @@
package la.moony.friends.vo;
import lombok.Data;
import java.time.Instant;
import java.util.List;
@Data
public class RssDetail {
private Channel channel;
@Data
public static class Channel {
private String title;
private String link;
private String description;
private List<Item> items;
}
@Data
public static class Item {
private String title;
private String link;
private String author;
private String description;
private String uri;
private Instant pubDate;
}
}