Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ dependencies {
testImplementation "org.springframework.cloud:spring-cloud-starter-openfeign"
testImplementation "com.squareup.okhttp3:mockwebserver:4.12.0"

// Jsoup (HTML parsing for OG tag crawling)
implementation 'org.jsoup:jsoup:1.18.3'

// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'

Expand Down Expand Up @@ -116,13 +119,18 @@ jacocoTestReport {
'**/*Request$*.class',
'**/*Response.class',
'**/*Response$*.class',
'**/*Req.class',
'**/*Req$*.class',
'**/*Res.class',
'**/*Res$*.class',
'**/config/**',
'**/configuration/**',
'**/exception/**',
'**/*Application.class',
'**/Q*.class',
'**/entity/**',
'**/domain/**/*Entity.class'
'**/domain/**/*Entity.class',
'**/domain/link/util/OgTagCrawler.class'
])
}))
}
Expand Down Expand Up @@ -153,13 +161,18 @@ jacocoTestCoverageVerification {
'**/*Request$*.class',
'**/*Response.class',
'**/*Response$*.class',
'**/*Req.class',
'**/*Req$*.class',
'**/*Res.class',
'**/*Res$*.class',
'**/config/**',
'**/configuration/**',
'**/exception/**',
'**/*Application.class',
'**/Q*.class',
'**/entity/**',
'**/domain/**/*Entity.class'
'**/domain/**/*Entity.class',
'**/domain/link/util/OgTagCrawler.class'
])
}))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,85 +3,88 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;

import com.sofa.linkiving.domain.link.dto.request.LinkCreateReq;
import com.sofa.linkiving.domain.link.dto.request.LinkMemoUpdateReq;
import com.sofa.linkiving.domain.link.dto.request.LinkTitleUpdateReq;
import com.sofa.linkiving.domain.link.dto.request.LinkUpdateReq;
import com.sofa.linkiving.domain.link.dto.request.MetaScrapeReq;
import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes;
import com.sofa.linkiving.domain.link.dto.response.LinkRes;
import com.sofa.linkiving.domain.link.dto.response.MetaScrapeRes;
import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse;
import com.sofa.linkiving.domain.link.enums.Format;
import com.sofa.linkiving.domain.member.entity.Member;
import com.sofa.linkiving.global.common.BaseResponse;
import com.sofa.linkiving.security.annotation.AuthMember;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;

@Tag(name = "Link", description = "링크 관리 API")
public interface LinkApi {

@Operation(summary = "메타 정보 수집", description = "URL의 OG 태그를 크롤링하여 메타 정보를 반환합니다")
BaseResponse<MetaScrapeRes> scrapeMetadata(
MetaScrapeReq request,
Member member
);

@Operation(summary = "URL 중복 체크", description = "저장하려는 URL이 이미 존재하는지 확인하고, 존재 시 linkId를 반환합니다")
ResponseEntity<BaseResponse<LinkDuplicateCheckRes>> checkDuplicate(
@RequestParam String url,
@AuthMember Member member
String url,
Member member
);

@Operation(summary = "링크 생성", description = "새로운 링크를 저장합니다")
ResponseEntity<BaseResponse<LinkRes>> createLink(
@Valid @RequestBody LinkCreateReq request,
@AuthMember Member member
LinkCreateReq request,
Member member
);

@Operation(summary = "링크 수정", description = "링크 정보를 수정합니다. null이 아닌 필드만 수정됩니다.")
ResponseEntity<BaseResponse<LinkRes>> updateLink(
@PathVariable Long id,
@Valid @RequestBody LinkUpdateReq request,
@AuthMember Member member
Long id,
LinkUpdateReq request,
Member member
);

@Operation(summary = "링크 삭제", description = "링크를 삭제합니다 (Soft Delete)")
ResponseEntity<BaseResponse<Void>> deleteLink(
@PathVariable Long id,
@AuthMember Member member
Long id,
Member member
);

@Operation(summary = "링크 조회", description = "링크 상세 정보를 조회합니다")
ResponseEntity<BaseResponse<LinkRes>> getLink(
@PathVariable Long id,
@AuthMember Member member
Long id,
Member member
);

@Operation(summary = "링크 목록 조회", description = "저장된 링크 목록을 페이징하여 조회합니다")
ResponseEntity<BaseResponse<Page<LinkRes>>> getLinkList(
Pageable pageable,
@AuthMember Member member
Member member
);

@Operation(summary = "링크 제목 수정", description = "링크 제목만 수정합니다")
ResponseEntity<BaseResponse<LinkRes>> updateTitle(
@PathVariable Long id,
@Valid @RequestBody LinkTitleUpdateReq request,
@AuthMember Member member
Long id,
LinkTitleUpdateReq request,
Member member
);

@Operation(summary = "링크 메모 수정", description = "링크 메모만 수정합니다")
ResponseEntity<BaseResponse<LinkRes>> updateMemo(
@PathVariable Long id,
@Valid @RequestBody LinkMemoUpdateReq request,
@AuthMember Member member
Long id,
LinkMemoUpdateReq request,
Member member
);

@Operation(summary = "요약 재생성", description = "요약을 재생성 하고 신규 요약 기존 요약, 기존 및 신규 요약 비교 정보을 제공합니다.")
BaseResponse<RecreateSummaryResponse> recreateSummary(
@PathVariable Long id,
@Valid @RequestParam @Schema(description = "요청 형식(CONCISE: 간결하게, DETAILED:자세하게)") Format format,
@AuthMember Member member
Long id,
@Schema(description = "요청 형식(CONCISE: 간결하게, DETAILED:자세하게)") Format format,
Member member
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
import com.sofa.linkiving.domain.link.dto.request.LinkMemoUpdateReq;
import com.sofa.linkiving.domain.link.dto.request.LinkTitleUpdateReq;
import com.sofa.linkiving.domain.link.dto.request.LinkUpdateReq;
import com.sofa.linkiving.domain.link.dto.request.MetaScrapeReq;
import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes;
import com.sofa.linkiving.domain.link.dto.response.LinkRes;
import com.sofa.linkiving.domain.link.dto.response.MetaScrapeRes;
import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse;
import com.sofa.linkiving.domain.link.enums.Format;
import com.sofa.linkiving.domain.link.facade.LinkFacade;
Expand All @@ -38,6 +40,16 @@ public class LinkController implements LinkApi {

private final LinkFacade linkFacade;

@Override
@PostMapping("/meta-scrape")
public BaseResponse<MetaScrapeRes> scrapeMetadata(
@Valid @RequestBody MetaScrapeReq request,
@AuthMember Member member
) {
MetaScrapeRes response = linkFacade.scrapeMetadata(request.url());
return BaseResponse.success(response, "메타 정보 수집 완료");
}

@Override
@GetMapping("/duplicate")
public ResponseEntity<BaseResponse<LinkDuplicateCheckRes>> checkDuplicate(
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/com/sofa/linkiving/domain/link/dto/OgTagDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.sofa.linkiving.domain.link.dto;

import lombok.Builder;

@Builder
public record OgTagDto(
String title,
String description,
String image,
String url
) {
public static final OgTagDto EMPTY = new OgTagDto("", "", "", "");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.sofa.linkiving.domain.link.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

public record MetaScrapeReq(
@Schema(description = "메타 정보를 수집할 URL", example = "https://example.com", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "URL은 필수입니다")
String url
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.sofa.linkiving.domain.link.dto.response;

import com.sofa.linkiving.domain.link.dto.OgTagDto;

import io.swagger.v3.oas.annotations.media.Schema;

public record MetaScrapeRes(
@Schema(description = "페이지 제목", example = "Example Domain")
String title,

@Schema(description = "페이지 설명", example = "This domain is for use in illustrative examples...")
String description,

@Schema(description = "이미지 URL", example = "https://example.com/image.jpg")
String image,

@Schema(description = "페이지 URL", example = "https://example.com")
String url
) {
public static MetaScrapeRes from(OgTagDto ogTag) {
return new MetaScrapeRes(
ogTag.title(),
ogTag.description(),
ogTag.image(),
ogTag.url()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public enum LinkErrorCode implements ErrorCode {

LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "L-001", "링크를 찾을 수 없습니다."),
DUPLICATE_URL(HttpStatus.BAD_REQUEST, "L-002", "이미 저장된 URL입니다."),
INVALID_URL(HttpStatus.BAD_REQUEST, "L-003", "유효하지 않은 URL 형식입니다."),
INVALID_URL_PROTOCOL(HttpStatus.BAD_REQUEST, "L-004", "허용되지 않은 프로토콜입니다. http 또는 https만 사용 가능합니다."),
INVALID_URL_PRIVATE_IP(HttpStatus.BAD_REQUEST, "L-005", "내부 네트워크 주소는 접근할 수 없습니다."),
SUMMARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "L-010", "요약 정보를 찾을 수 없습니다.");

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.sofa.linkiving.domain.link.dto.OgTagDto;
import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes;
import com.sofa.linkiving.domain.link.dto.response.LinkRes;
import com.sofa.linkiving.domain.link.dto.response.MetaScrapeRes;
import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse;
import com.sofa.linkiving.domain.link.enums.Format;
import com.sofa.linkiving.domain.link.service.LinkService;
import com.sofa.linkiving.domain.link.service.SummaryService;
import com.sofa.linkiving.domain.link.util.OgTagCrawler;
import com.sofa.linkiving.domain.member.entity.Member;

import lombok.RequiredArgsConstructor;
Expand All @@ -21,6 +24,7 @@
public class LinkFacade {

private final LinkService linkService;
private final OgTagCrawler ogTagCrawler;
private final SummaryService summaryService;

public LinkRes createLink(Member member, String url, String title, String memo, String imageUrl) {
Expand Down Expand Up @@ -74,4 +78,10 @@ public RecreateSummaryResponse recreateSummary(Member member, Long linkId, Forma
.comparison(comparison)
.build();
}

@Transactional(readOnly = true)
public MetaScrapeRes scrapeMetadata(String url) {
OgTagDto ogTag = ogTagCrawler.crawl(url);
return MetaScrapeRes.from(ogTag);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.sofa.linkiving.domain.link.util;

import java.io.IOException;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.springframework.stereotype.Component;

import com.sofa.linkiving.domain.link.dto.OgTagDto;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
public class OgTagCrawler {

private static final int TIMEOUT_MS = 5000;
private final UrlValidator urlValidator;

public OgTagCrawler(UrlValidator urlValidator) {
this.urlValidator = urlValidator;
}

public OgTagDto crawl(String url) {
// SSRF 방어: URL 안전성 검증
urlValidator.validateSafeUrl(url);

try {
Document document = Jsoup.connect(url)
.timeout(TIMEOUT_MS)
.userAgent("Mozilla/5.0")
.get();

return OgTagDto.builder()
.title(getMetaTag(document, "og:title"))
.description(getMetaTag(document, "og:description"))
.image(getMetaTag(document, "og:image"))
.url(getMetaTag(document, "og:url"))
.build();

} catch (IOException e) {
log.warn("OG 태그 크롤링 실패: {}", url, e);
return OgTagDto.EMPTY;
}
}

private String getMetaTag(Document document, String property) {
return document.select("meta[property=" + property + "]")
.attr("content");
}
}
Loading