新增微博图片下载与显示

This commit is contained in:
anian
2026-04-13 17:23:27 +08:00
parent 5a13068143
commit 3e9d0b4441
4 changed files with 145 additions and 30 deletions
@@ -27,16 +27,30 @@ 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.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.DefaultController;
import run.halo.app.extension.controller.DefaultQueue;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.controller.RequestQueue;
import run.halo.app.extension.router.selector.FieldSelector;
import java.io.InputStream;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.springframework.data.domain.Sort.Order.asc;
import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations;
@@ -45,10 +59,24 @@ 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 {
public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>, SmartLifecycle {
private static final Logger log = LoggerFactory.getLogger(RssSyncReconciler.class);
// 新增:正则匹配所有img标签的src属性
private static final Pattern IMG_SRC_PATTERN = Pattern.compile(
"<img[^>]*?src\\s*=\\s*(['\"])(.*?)\\1[^>]*?>",
Pattern.CASE_INSENSITIVE
);
private static final Pattern MW2000_IMAGE_FILE_PATTERN = Pattern.compile(
".*/mw2000/([^/?#]+)(?:[?#].*)?$",
Pattern.CASE_INSENSITIVE
);
private static final String IMAGE_CDN_BASE_URL = "https://cdn.img2ipfs.com/weibo/middle/";
private static final Path IMAGE_HOST_DIR = Path.of("/root/.halo2/attachments/upload/image-host/");
private volatile boolean running = false;
private final ExtensionClient client;
@@ -56,7 +84,6 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
private final Controller controller;
private final ObjectMapper objectMapper = Unstructured.OBJECT_MAPPER;
public RssSyncReconciler(ExtensionClient client) {
this.client = client;
queue = new DefaultQueue<>(Instant::now);
@@ -82,12 +109,12 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
newRssFeedSyncLog.setLinkName(linkName);
if (StringUtils.isNotEmpty(rssUrl) && !isContains) {
tryToSynchronizeFriendPost(newRssFeedSyncLog, link, sum);
}else if (StringUtils.isEmpty(rssUrl)) {
} 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) {
} else if (isContains) {
newRssFeedSyncLog.setSyncTime(Instant.now());
newRssFeedSyncLog.setState(RssFeedSyncLog.RssFeedSyncLogState.failed);
newRssFeedSyncLog.setFailureReason("admin disables sync");
@@ -101,7 +128,7 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
rssFeedSyncLog.setFailureReason(newRssFeedSyncLog.getFailureReason());
rssFeedSyncLog.setFailureMessage(newRssFeedSyncLog.getFailureMessage());
client.update(rssFeedSyncLog);
}else {
} else {
client.create(newRssFeedSyncLog);
}
return Result.doNotRetry();
@@ -121,10 +148,10 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
linkRss = rssUri;
annotations.remove("rss_uri");
annotations.put("rss_url", rssUri);
}else {
annotations.put("is_request","true");
} else {
annotations.put("is_request", "true");
if (StringUtils.isNotEmpty(linkRss)) {
annotations.put("rss_url",linkRss);
annotations.put("rss_url", linkRss);
}
}
updateLink(link);
@@ -133,7 +160,6 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
return rssUrl;
}
public void updateLink(Link link) {
Map extensionMap = objectMapper.convertValue(link, Map.class);
var extension = new Unstructured(extensionMap);
@@ -151,19 +177,19 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
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())
equal("spec.postLink", rssPost.getSpec().getPostLink())
)
));
long total = client.listBy(FriendPost.class, friendPostListOptions,
PageRequestImpl.ofSize(1)).getTotal();
if (total==0) {
if (total == 0) {
FriendPost friendPost = new FriendPost();
// 设置元数据才能保存
friendPost.setMetadata(new Metadata());
@@ -175,23 +201,42 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
friendPostSpec.setAuthorUrl(link.getSpec().getUrl());
friendPostSpec.setLinkName(linkName);
String description = friendPost.getSpec().getDescription();
//解析html内容转换成文本
if (StringUtils.isNotEmpty(description)){
description = CommonUtils.parseAndTruncateHtml2Text(description, 100000);
// 先提取图片文件名覆盖标题,再将 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));
}
}
if (!imageFileNames.isEmpty()) {
String imageTitle = "img:::" + String.join(",", imageFileNames);
friendPostSpec.setTitle(imageTitle);
// 通知图片处理服务
notifyImageService(imageTitle);
}
String pureText = CommonUtils.parseAndTruncateHtml2Text(description, 100000);
String regexp = "[ *|\\s*]*";
description = description.replaceFirst(regexp, "").trim();
pureText = pureText.replaceFirst(regexp, "").trim();
String weiboVideoRegex = "\\s[^\\s]*的微博视频 视频无法显示,请前往微博视频观看。";
description = description.replaceAll(weiboVideoRegex, "");
friendPostSpec.setDescription(description);
pureText = pureText.replaceAll(weiboVideoRegex, "");
friendPostSpec.setDescription(pureText);
}
client.create(friendPost);
}
}
delFriendPost(linkName,sum);
delFriendPost(linkName, sum);
}
}
public List<FriendPost> fetchFriendPost(String rssAddress,int postsLimit, RssFeedSyncLog sync) {
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)));
@@ -205,7 +250,7 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
SyndContent content = entry.getDescription();
if (content != null) {
description = content.getValue();
}else {
} else {
List<SyndContent> contents = entry.getContents();
if (!contents.isEmpty()) {
description = contents.get(0).getValue();
@@ -235,10 +280,10 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
return friendPostList;
}
public void delFriendPost(String linkName,Integer sum) {
public void delFriendPost(String linkName, Integer sum) {
var friendPostListOptions = new ListOptions();
friendPostListOptions.setFieldSelector(FieldSelector.of(
equal("spec.linkName",linkName)
equal("spec.linkName", linkName)
));
long total = client.listBy(FriendPost.class, friendPostListOptions, PageRequestImpl.ofSize(1)).getTotal();
if (total > sum) {
@@ -253,6 +298,44 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
}
}
/**
* 通知图片处理服务有新的图片需要处理
* @param imageTitle 图片标题,格式为 "img:::file1,file2,..."
*/
private void notifyImageService(String imageTitle) {
if (StringUtils.isEmpty(imageTitle)) {
return;
}
new Thread(() -> downloadImagesByTitle(imageTitle)).start();
}
private void downloadImagesByTitle(String imageTitle) {
if (!imageTitle.startsWith("img:::")) {
return;
}
try {
Files.createDirectories(IMAGE_HOST_DIR);
String[] fileNames = imageTitle.substring("img:::".length()).split(",");
for (String fileName : fileNames) {
String trimmedName = fileName.trim();
if (StringUtils.isEmpty(trimmedName)) {
continue;
}
String encodedFileName = URLEncoder.encode(trimmedName, StandardCharsets.UTF_8)
.replace("+", "%20");
String downloadUrl = IMAGE_CDN_BASE_URL + encodedFileName;
Path targetPath = IMAGE_HOST_DIR.resolve(trimmedName);
try (InputStream inputStream = new URL(downloadUrl).openStream()) {
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
}
}
} catch (Exception e) {
log.warn("Failed to download images for title: {}", imageTitle, e);
}
}
static int pageNullSafe(Integer page) {
return ObjectUtils.defaultIfNull(page, 1);
}
@@ -261,7 +344,6 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
return ObjectUtils.defaultIfNull(size, 10);
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return new DefaultController<>(
@@ -293,10 +375,10 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
@EventListener(RssFeedSyncEvent.class)
public void onReplyEvent(RssFeedSyncEvent event) {
var request = new Request(event.getLink(),event.getSum(),event.getDisableSyncList());
var request = new Request(event.getLink(), event.getSum(), event.getDisableSyncList());
queue.addImmediately(request);
}
public record Request(Link link,int sum,List<String> disableSyncList) {
public record Request(Link link, int sum, List<String> disableSyncList) {
}
}
}