屏蔽空内容;接入搜索引擎;新增详情页面

This commit is contained in:
anian
2026-07-18 19:41:46 +08:00
parent 239bbf84f3
commit edd12b6488
10 changed files with 422 additions and 43 deletions
@@ -242,44 +242,40 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
friendPostSpec.setAuthorUrl(link.getSpec().getUrl());
friendPostSpec.setLinkName(linkName);
String description = friendPost.getSpec().getDescription();
// 先提取图片和视频 poster 文件名覆盖标题,再将 HTML 描述转换为纯文本
if (StringUtils.isNotEmpty(description)) {
List<String> imageFileNames = new ArrayList<>();
Matcher matcher = IMG_SRC_PATTERN.matcher(description);
while (matcher.find()) {
String imgSrc = matcher.group(2);
Matcher mw2000Matcher = MW2000_IMAGE_FILE_PATTERN.matcher(imgSrc);
if (mw2000Matcher.matches()) {
imageFileNames.add(mw2000Matcher.group(1));
}
}
Matcher posterMatcher = VIDEO_POSTER_PATTERN.matcher(description);
while (posterMatcher.find()) {
String posterSrc = posterMatcher.group(2);
String posterFileName = extractFileNameFromUrl(posterSrc);
if (StringUtils.isNotBlank(posterFileName)) {
imageFileNames.add(posterFileName);
}
}
if (enableWeiboImageRecordAndDownload && !imageFileNames.isEmpty()) {
String imageFiles = String.join(",", imageFileNames);
friendPostSpec.setImageFiles(imageFiles);
// 通知图片处理服务
notifyImageService(imageFiles, imageSavePath, imageBaseUrl,
imageReferer);
}
String pureText = CommonUtils.parseAndTruncateHtml2Text(description, 100000);
String regexp = "[ *|\\s*]*";
pureText = pureText.replaceFirst(regexp, "").trim();
String weiboVideoRegex = "\\s[^\\s]*的微博视频 视频无法显示,请前往微博视频观看。";
pureText = pureText.replaceAll(weiboVideoRegex, "");
friendPostSpec.setDescription(pureText);
String pureText = normalizeDescription(description);
if (StringUtils.isBlank(pureText)) {
continue;
}
// 提取图片和视频 poster 文件名,并保存过滤后的纯文本描述
List<String> imageFileNames = new ArrayList<>();
Matcher matcher = IMG_SRC_PATTERN.matcher(description);
while (matcher.find()) {
String imgSrc = matcher.group(2);
Matcher mw2000Matcher = MW2000_IMAGE_FILE_PATTERN.matcher(imgSrc);
if (mw2000Matcher.matches()) {
imageFileNames.add(mw2000Matcher.group(1));
}
}
Matcher posterMatcher = VIDEO_POSTER_PATTERN.matcher(description);
while (posterMatcher.find()) {
String posterSrc = posterMatcher.group(2);
String posterFileName = extractFileNameFromUrl(posterSrc);
if (StringUtils.isNotBlank(posterFileName)) {
imageFileNames.add(posterFileName);
}
}
if (enableWeiboImageRecordAndDownload && !imageFileNames.isEmpty()) {
String imageFiles = String.join(",", imageFileNames);
friendPostSpec.setImageFiles(imageFiles);
// 通知图片处理服务
notifyImageService(imageFiles, imageSavePath, imageBaseUrl,
imageReferer);
}
friendPostSpec.setDescription(pureText);
client.create(friendPost);
}
}
@@ -287,6 +283,17 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
}
}
private String normalizeDescription(String description) {
if (StringUtils.isEmpty(description)) {
return "";
}
String pureText = CommonUtils.parseAndTruncateHtml2Text(description, 100000);
String regexp = "[ *|\\s*]*";
pureText = pureText.replaceFirst(regexp, "").trim();
String weiboVideoRegex = "\\s[^\\s]*的微博视频 视频无法显示,请前往微博视频观看。";
return pureText.replaceAll(weiboVideoRegex, "").trim();
}
public List<FriendPost> fetchFriendPost(String rssAddress, int postsLimit, RssFeedSyncLog sync) {
List<FriendPost> friendPostList = new ArrayList<>();
try {
@@ -498,4 +505,4 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
String imageSavePath, String imageBaseUrl,
String imageReferer, int parallelCount) {
}
}
}
@@ -40,6 +40,7 @@ public class FriendRouter {
RouterFunction<ServerResponse> friendTemplateRoute() {
return RouterFunctions.route().GET("/friends",this::handlerFunction)
.GET("/friends/page/{page:\\d+}",this::handlerFunction)
.GET("/friends/{friendPostName:\\S+}", this::friendPostDetail)
.build();
}
@@ -54,6 +55,16 @@ public class FriendRouter {
));
}
private Mono<ServerResponse> friendPostDetail(ServerRequest request) {
String friendPostName = request.pathVariable("friendPostName");
return templateNameResolver.resolveTemplateNameOrDefault(request.exchange(), "friend-post")
.flatMap(templateName -> ServerResponse.ok().render(templateName,
Map.of(ModelConst.TEMPLATE_ID, templateName,
"friend", friendFinder.getByName(friendPostName),
"title", getFriendTitle())
));
}
Mono<String> getFriendTitle() {
return this.settingFetcher.get("base").map(
setting -> setting.get("title").asText("友链朋友圈")).defaultIfEmpty(
@@ -134,4 +145,4 @@ public class FriendRouter {
return NumberUtils.toInt(page, 1);
}
}
}
@@ -0,0 +1,101 @@
package la.moony.friends.search;
import static la.moony.friends.search.FriendPostHaloDocumentsProvider.FRIEND_POST_DOCUMENT_TYPE;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import la.moony.friends.extension.FriendPost;
import la.moony.friends.util.CommonUtils;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.search.HaloDocument;
@Component
@RequiredArgsConstructor
public class FriendPostDocumentConverter implements Converter<FriendPost, Mono<HaloDocument>> {
private static final String EMPTY_CONTENT = "无内容";
private final ExternalUrlSupplier externalUrlSupplier;
@Override
@NonNull
public Mono<HaloDocument> convert(FriendPost friendPost) {
var spec = friendPost.getSpec();
var author = authorName(friendPost);
var title = defaultIfBlank(spec == null ? null : spec.getTitle(), EMPTY_CONTENT);
var description = defaultIfBlank(plainDescription(spec), EMPTY_CONTENT);
var haloDoc = new HaloDocument();
haloDoc.setMetadataName(friendPost.getMetadata().getName());
haloDoc.setType(FRIEND_POST_DOCUMENT_TYPE);
haloDoc.setId(haloDocId(friendPost));
haloDoc.setTitle(title);
haloDoc.setDescription(description);
haloDoc.setContent(content(title, author, description));
haloDoc.setExposed(true);
haloDoc.setTags(tags(author));
haloDoc.setOwnerName(author);
haloDoc.setUpdateTimestamp(updateTimestamp(friendPost));
haloDoc.setCreationTimestamp(creationTimestamp(friendPost));
haloDoc.setPermalink(permalink(friendPost));
haloDoc.setPublished(true);
return Mono.just(haloDoc);
}
String haloDocId(FriendPost friendPost) {
return FRIEND_POST_DOCUMENT_TYPE + '-' + friendPost.getMetadata().getName();
}
private String authorName(FriendPost friendPost) {
var author = Optional.ofNullable(friendPost.getSpec())
.map(FriendPost.FriendPostSpec::getAuthor)
.orElse(null);
return StringUtils.defaultIfBlank(author, EMPTY_CONTENT);
}
private String plainDescription(FriendPost.FriendPostSpec spec) {
if (spec == null) {
return null;
}
return CommonUtils.parseAndTruncateHtml2Text(defaultString(spec.getDescription()), 300);
}
private String content(String title, String author, String description) {
return String.join(" ", title, author, description).trim();
}
private List<String> tags(String author) {
return StringUtils.isBlank(author) ? List.of() : List.of(author);
}
private Instant updateTimestamp(FriendPost friendPost) {
var spec = friendPost.getSpec();
return Optional.ofNullable(spec)
.map(FriendPost.FriendPostSpec::getPubDate)
.orElse(creationTimestamp(friendPost));
}
private Instant creationTimestamp(FriendPost friendPost) {
return Optional.ofNullable(friendPost.getMetadata().getCreationTimestamp())
.orElse(Instant.EPOCH);
}
private String permalink(FriendPost friendPost) {
var externalUrl = externalUrlSupplier.get();
return externalUrl.resolve("friends/" + friendPost.getMetadata().getName()).toString();
}
private static String defaultString(String value) {
return StringUtils.defaultString(value);
}
private static String defaultIfBlank(String value, String defaultValue) {
return StringUtils.defaultIfBlank(value, defaultValue);
}
}
@@ -0,0 +1,59 @@
package la.moony.friends.search;
import la.moony.friends.extension.FriendPost;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
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.PageRequest;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.search.HaloDocument;
import run.halo.app.search.HaloDocumentsProvider;
import static run.halo.app.extension.index.query.QueryFactory.isNull;
@Component
@RequiredArgsConstructor
public class FriendPostHaloDocumentsProvider implements HaloDocumentsProvider {
public static final String FRIEND_POST_DOCUMENT_TYPE = "friendpost.friend.moony.la";
private static final int PAGE_SIZE = 200;
private final ReactiveExtensionClient client;
private final FriendPostDocumentConverter converter;
@Override
public Flux<HaloDocument> fetchAll() {
var options = new ListOptions();
var notDeleted = isNull("metadata.deletionTimestamp");
options.setFieldSelector(FieldSelector.of(notDeleted));
var pageRequest = createPageRequest();
return client.listBy(FriendPost.class, options, pageRequest)
.expand(result -> result.hasNext()
? client.listBy(FriendPost.class, options, nextPage(result, pageRequest.getSort()))
: Mono.empty())
.flatMap(result -> Flux.fromStream(result.get()))
.flatMap(converter::convert);
}
@Override
public String getType() {
return FRIEND_POST_DOCUMENT_TYPE;
}
private PageRequest createPageRequest() {
return PageRequestImpl.of(1, PAGE_SIZE,
Sort.by("metadata.creationTimestamp", "metadata.name"));
}
private static PageRequest nextPage(ListResult<FriendPost> result, Sort sort) {
return PageRequestImpl.of(result.getPage() + 1, result.getSize(), sort);
}
}
@@ -0,0 +1,61 @@
package la.moony.friends.search;
import java.util.List;
import java.util.Set;
import la.moony.friends.extension.FriendPost;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
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 run.halo.app.search.event.HaloDocumentAddRequestEvent;
import run.halo.app.search.event.HaloDocumentDeleteRequestEvent;
@Component
@RequiredArgsConstructor
public class FriendPostSearchReconciler implements Reconciler<Reconciler.Request> {
private static final String FINALIZER = "friend-post-search-protection";
private final ApplicationEventPublisher eventPublisher;
private final ExtensionClient client;
private final FriendPostDocumentConverter converter;
@Override
public Result reconcile(Request request) {
client.fetch(FriendPost.class, request.name()).ifPresent(friendPost -> {
if (ExtensionUtil.isDeleted(friendPost)) {
if (ExtensionUtil.removeFinalizers(friendPost.getMetadata(), Set.of(FINALIZER))) {
eventPublisher.publishEvent(
new HaloDocumentDeleteRequestEvent(this,
List.of(converter.haloDocId(friendPost)))
);
client.update(friendPost);
}
return;
}
ExtensionUtil.addFinalizers(friendPost.getMetadata(), Set.of(FINALIZER));
var haloDoc = converter.convert(friendPost)
.blockOptional().orElseThrow();
eventPublisher.publishEvent(
new HaloDocumentAddRequestEvent(this, List.of(haloDoc)));
client.update(friendPost);
});
return Result.doNotRetry();
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new FriendPost())
.workerCount(1)
.build();
}
}