diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e9348e6..fca9bb9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,7 +37,11 @@ jobs:
run: pnpm install
- name: Sync Python workspace
- run: uv sync
+ # --frozen: 재해석하지 않고 uv.lock에 적힌 버전 그대로 설치한다.
+ # 그냥 uv sync면 CI가 로컬과 다른 버전으로 통과할 수 있다.
+ # --locked는 쓸 수 없다. uv.lock이 상대 exclude-newer(span)를 기록하는데
+ # 그건 사용자 전역 uv 설정이라 CI에는 없고, 그 차이만으로 재해석이 필요하다고 판정한다.
+ run: uv sync --frozen
- name: Install Playwright browsers
run: uv run playwright install
diff --git a/AGENTS.md b/AGENTS.md
index b864995..a114de9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -51,6 +51,19 @@ uv run skim source list --platform blogs
uv run skim source sync # feed_config -> tracked_sources (멱등)
uv run skim source refresh --all # tier 재관측, 죽은 피드 탐지
uv run skim source list --emit markdown > docs/SOURCES.md # 인벤토리 갱신
+uv run skim source export --out sources.opml # 소스 목록 백업
+uv run skim source import sources.opml --platform blogs # 되읽기 (멱등)
+
+# 데이터 꺼내기 (AI에 넘기기 전에 반드시 줄인다)
+uv run skim research "topic" --fields platform,title,url # 전문 없이 목록만
+uv run skim research "topic" --max-chars 2000 # 본문 절단 + truncated 표시
+uv run skim bundle --days 1 --group-by platform # topic 없이 최근 글 본문까지
+uv run skim export ./out --days 7 --unread # 마크다운 파일로
+uv run skim mark 12 34 --state read # 소비 상태
+
+# 운영
+uv run skim backup --keep 3 # 온라인 백업 + quick_check
+uv run skim doctor --strict # warning 있으면 exit 1
# 기타
uv run skim platforms # 지원 플랫폼 목록
@@ -113,6 +126,16 @@ CLI (uv run skim ...) → skim_cli.cli → skim_core.crawlers.REGISTRY lookup
| youtube | `## YouTube Comments` | 영상당 yt-dlp 1회 |
| producthunt | `## Product Hunt Comments` | 제품당 1건 (PH 제품 페이지) |
| threads | `## Threads Replies` | 답글 1개 이상인 게시물 전부, 게시물당 1건 |
+| lobsters | `## Lobsters Comments` | 게시물당 1건 (초당 1요청). 같은 응답의 `description_plain`이 본문 폴백 |
+| bluesky | `## Bluesky Replies` | 답글 1개 이상인 게시물, 게시물당 1건 (무인증 postThread) |
+
+- **댓글 조회는 `comments`가 0보다 클 때만 한다.** 0건인 글을 조회하면 "유효 댓글 없음"과
+ "HTTP 실패"가 둘 다 `None`이라 구분되지 않는다. reddit은 그 때문에 조용한 서브레딧에서
+ 0건 글 3개가 연속되면 서킷브레이커가 남은 게시글 전체의 댓글 수집을 끊었다.
+- **파싱까지 `try` 안에 넣는다.** HTTP 호출만 감싸면 상류 응답 구조가 바뀔 때 파싱 예외가
+ 크롤 루프까지 올라가 그 회차의 게시글이 통째로 저장 0건이 된다. "댓글 실패가 게시글
+ 저장을 막지 않는다"는 계약이 실제로 깨져 있던 자리다. 회귀는
+ `tests/test_comment_failure_isolation.py`가 잡는다.
- threads 답글은 타임라인 GraphQL이 주지 않는다. 대신 게시물 문서의 SSR 페이로드가
답글까지 담고 있고 로그인도 필요 없어서, persisted query 좌표(`doc_id`)를 새로 들지 않는다.
@@ -139,9 +162,26 @@ CLI (uv run skim ...) → skim_cli.cli → skim_core.crawlers.REGISTRY lookup
| 유형 | 위치 | 옵션 기준 | 플랫폼 |
|------|------|-----------|--------|
-| Feed | `packages/skim-core/src/skim_core/crawlers/feed/` | `since` | hackernews, geeknews, youtube, producthunt, arxiv, huggingface, everyto, blogs, ailabs |
+| Feed | `packages/skim-core/src/skim_core/crawlers/feed/` | `since` | hackernews, lobsters, geeknews, youtube, producthunt, arxiv, huggingface, everyto, blogs, ailabs, bluesky |
| API | `packages/skim-core/src/skim_core/crawlers/api/` | `count` | threads, x, linkedin, reddit |
+#### 좁은 창에서 0건이 나오는 소스
+
+발행일이 실제 게시 시점보다 밀리는 소스가 있다. 데일리 배치는 `crawl all --days 1`로
+돌기 때문에, 기본값에만 보정을 넣으면 정작 운영 경로에서는 매번 0건이 된다.
+보정은 `skim_cli.cli.min_lookback_days()`에 **바닥값으로** 넣는다. `days is None`일 때만
+적용되는 분기에 넣으면 `--days 1`이 그걸 덮어쓴다 (arxiv가 그래서 이틀간 멈춰 있었다).
+
+거르는 기준 필드도 확인한다. 큐레이션 목록은 원문 발행일이 아니라 목록에 올린 날짜로
+걸러야 한다 (huggingface는 `paper.submittedOnDailyAt`, `publishedAt`은 arXiv 발행일이라
+며칠에서 몇 주 밀려 있다).
+
+#### 새 소스를 넣기 전에
+
+`fetch_feed`는 발행일이 없는 엔트리를 버린다. 200에 엔트리가 오더라도 날짜 필드가
+없으면 등록해도 매번 0건인데 겉보기엔 멀쩡하다. 실측하지 않은 URL은 넣지 않는다.
+떨어진 후보는 `docs/TODO.md`의 "Rejected Sources"에 이유와 함께 남긴다.
+
- Feed 크롤러: `since` 유무에 따라 RSS/API 모드 자동 전환
- API 크롤러: `data/sessions/{platform}_session.json` 세션 쿠키 재사용
- Reddit API 크롤러: subreddit listing은 verification challenge 해제 후 JSON endpoint 호출, 홈 피드는 로그인 세션 기반 `best.json` 호출
@@ -150,7 +190,13 @@ CLI (uv run skim ...) → skim_cli.cli → skim_core.crawlers.REGISTRY lookup
- `packages/skim-cli/src/skim_cli/cli.py`: Typer CLI 엔트리포인트
- `packages/skim-core/src/skim_core/models.py`: `Post` Pydantic 모델
-- `packages/skim-core/src/skim_core/db.py`: SQLite WAL 모드, `UNIQUE(platform, external_id)` 중복 제거
+- `packages/skim-core/src/skim_core/db.py`: SQLite WAL 모드, `UNIQUE(platform, external_id)` 중복 제거.
+ **연결을 여는 함수는 `try/finally`로 닫는다** — `commit()`/`close()`를 try 밖에 두면
+ `sqlite3.Error`가 아닌 예외에서 RESERVED 락이 남아, 뒤따르는 쓰기가 60초를 기다리다
+ `database is locked`로 죽으며 원래 오류를 덮는다.
+ `canonical_body()`는 정본 본문 판정의 단일 소스다. 저장과 결손 집계가 함께 써야 한다
+ (따로 판정하던 때 API형 4종이 정상 저장돼도 매일 "전량 실패"로 찍혔다).
+ 소비 상태(읽음/보관)는 `feedback` 테이블을 쓴다. `posts`에 컬럼을 더하지 않는다.
- `packages/skim-core/src/skim_core/enrichment.py`: `bunx defuddle`, `yt-dlp`, transcript 정리
- `packages/skim-core/src/skim_core/comments.py`: 플랫폼 중립 `Comment`와 본문 댓글 섹션 합성
- `packages/skim-core/src/skim_core/feed_utils.py`: RSS/Atom 파싱, KST 변환. `FEED_HEADERS`의 Chrome 버전은 news.hada.io 차단선에 걸리므로 함부로 낮추지 않는다
diff --git a/README.ko.md b/README.ko.md
index 06cefdf..93e226e 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -34,15 +34,17 @@ Skim은 여러 public feed와 세션 기반 social source에서 post를 수집
| 유형 | 플랫폼 | 소스 |
|---|---|---|
-| Feed | Hacker News | hnrss.org |
+| Feed | Hacker News | hnrss.org newest + Show + Ask |
+| Feed | Lobsters | RSS + 게시물별 JSON (댓글) |
| Feed | GeekNews | news.hada.io Atom |
| Feed | YouTube | RSS + `yt-dlp` |
| Feed | Product Hunt | RSS |
-| Feed | arXiv | Atom API |
+| Feed | arXiv | Atom API (cs.AI, cs.CL, cs.LG, cs.CV) |
| Feed | Hugging Face | Daily Papers JSON API |
| Feed | Every.to | RSS feeds |
| Feed | Blogs | `PERSONAL_BLOGS`의 RSS feeds |
-| Feed | AI Labs | OpenAI RSS, Anthropic pages, LangChain blog |
+| Feed | AI Labs | OpenAI, Anthropic, LangChain, DeepMind, Google Research, Hugging Face, Mistral |
+| Feed | Bluesky | 공개 XRPC (로그인 불필요) |
| API | Threads | Instagram Private API |
| API | X | `twitter-api-client` 기반 GraphQL |
| API | LinkedIn | Voyager GraphQL |
@@ -80,6 +82,10 @@ uv run skim crawl reddit --subreddit python --sort hot --count 10
```bash
uv run skim research "AI video" --days 7 --emit summary
uv run skim research "vector database" --sources hackernews,arxiv --emit json
+
+# `--emit json`은 본문을 전문으로 싣는다. 에이전트에 넘기기 전에 줄인다.
+uv run skim research "agents" --fields platform,title,url,timestamp
+uv run skim research "agents" --max-chars 2000 # 자른 글에는 truncated가 붙는다
```
로컬 데이터 점검과 bundle 생성:
@@ -87,9 +93,21 @@ uv run skim research "vector database" --sources hackernews,arxiv --emit json
```bash
uv run skim doctor
uv run skim doctor --platform reddit
+uv run skim doctor --strict # warning이 있으면 exit 1 (cron 연동용)
+uv run skim backup --keep 3 # 온라인 백업 + quick_check
uv run skim refresh-plan --days 1
uv run skim coverage --days 7 --emit json
uv run skim bundle "AI video" --days 7
+uv run skim bundle --days 1 --group-by platform # topic 없이: 최근 글을 본문까지
+```
+
+SQLite 밖으로 꺼내기:
+
+```bash
+uv run skim export ./exported --days 7 # 글 하나당 마크다운 파일 1개
+uv run skim export ./exported --days 7 --format json
+uv run skim source export --out sources.opml # 소스 목록 공유·백업
+uv run skim source import sources.opml --platform blogs
```
## Agent Skill
diff --git a/README.md b/README.md
index 32d4f37..7419ca3 100644
--- a/README.md
+++ b/README.md
@@ -34,15 +34,17 @@ Skim collects posts from multiple public feeds and session-based social sources,
| Type | Platform | Source |
|---|---|---|
-| Feed | Hacker News | hnrss.org |
+| Feed | Hacker News | hnrss.org newest + Show + Ask |
+| Feed | Lobsters | RSS + per-story JSON (comments) |
| Feed | GeekNews | news.hada.io Atom |
| Feed | YouTube | RSS + `yt-dlp` |
| Feed | Product Hunt | RSS |
-| Feed | arXiv | Atom API |
+| Feed | arXiv | Atom API (cs.AI, cs.CL, cs.LG, cs.CV) |
| Feed | Hugging Face | Daily Papers JSON API |
| Feed | Every.to | RSS feeds |
| Feed | Blogs | RSS feeds in `PERSONAL_BLOGS` |
-| Feed | AI Labs | OpenAI RSS, Anthropic pages, LangChain blog |
+| Feed | AI Labs | OpenAI, Anthropic, LangChain, DeepMind, Google Research, Hugging Face, Mistral |
+| Feed | Bluesky | Public XRPC (no login) |
| API | Threads | Instagram Private API |
| API | X | GraphQL via `twitter-api-client` |
| API | LinkedIn | Voyager GraphQL |
@@ -80,6 +82,10 @@ Search the local post store:
```bash
uv run skim research "AI video" --days 7 --emit summary
uv run skim research "vector database" --sources hackernews,arxiv --emit json
+
+# `--emit json` carries every body in full. Trim it before handing it to an agent.
+uv run skim research "agents" --fields platform,title,url,timestamp
+uv run skim research "agents" --max-chars 2000 # sets `truncated` on shortened posts
```
Inspect and package local data:
@@ -87,9 +93,21 @@ Inspect and package local data:
```bash
uv run skim doctor
uv run skim doctor --platform reddit
+uv run skim doctor --strict # exit 1 on any warning (for cron)
+uv run skim backup --keep 3 # online backup + quick_check
uv run skim refresh-plan --days 1
uv run skim coverage --days 7 --emit json
uv run skim bundle "AI video" --days 7
+uv run skim bundle --days 1 --group-by platform # no topic: recent posts with bodies
+```
+
+Get posts out of SQLite:
+
+```bash
+uv run skim export ./exported --days 7 # one Markdown file per post
+uv run skim export ./exported --days 7 --format json
+uv run skim source export --out sources.opml # share or back up the source list
+uv run skim source import sources.opml --platform blogs
```
## Agent Skill
diff --git a/apps/desktop/Sources/SkimDesktopApp/ContentView.swift b/apps/desktop/Sources/SkimDesktopApp/ContentView.swift
index 464dfaa..98360cb 100644
--- a/apps/desktop/Sources/SkimDesktopApp/ContentView.swift
+++ b/apps/desktop/Sources/SkimDesktopApp/ContentView.swift
@@ -591,6 +591,16 @@ struct ContentView: View {
.buttonStyle(.bordered)
.disabled(transcribingPostID != nil)
}
+ Button {
+ toggleRead(post)
+ } label: {
+ Label(
+ post.isRead ? "읽음" : "안 읽음",
+ systemImage: post.isRead ? "checkmark.circle.fill" : "circle"
+ )
+ }
+ .buttonStyle(.bordered)
+ .help("CLI의 `skim mark`와 같은 상태를 씁니다")
readerModeToggle
if let url = post.url {
Button {
@@ -1589,6 +1599,22 @@ struct ContentView: View {
}
}
+ /// 읽음 상태를 토글한다. CLI의 `skim mark`와 같은 `feedback` 행을 쓴다.
+ ///
+ /// DB에 직접 쓰고 목록만 다시 읽는다. `uv run skim`을 띄우면 토글 한 번에
+ /// 수백 밀리초가 걸려 체크박스로 쓸 수 없다.
+ private func toggleRead(_ post: DashboardPost) {
+ Task { @MainActor in
+ do {
+ let database = try SkimDatabase(path: WorkspaceLocator.defaultDatabasePath())
+ try database.setPostState(id: post.id, state: post.isRead ? nil : "read")
+ reloadSourcePosts()
+ } catch {
+ transcribeError = localizedError(error)
+ }
+ }
+ }
+
private func transcribe(_ post: DashboardPost) {
guard let url = post.url else {
return
diff --git a/apps/desktop/Sources/SkimDesktopCore/DashboardModels.swift b/apps/desktop/Sources/SkimDesktopCore/DashboardModels.swift
index 50cb3d4..3a92ef8 100644
--- a/apps/desktop/Sources/SkimDesktopCore/DashboardModels.swift
+++ b/apps/desktop/Sources/SkimDesktopCore/DashboardModels.swift
@@ -18,6 +18,8 @@ public struct DashboardPost: Identifiable, Equatable, Sendable {
public let crawledAt: String
/// 크롤러가 extra JSON에 남긴 첨부/대표 이미지 CDN URL (SNS images + og:image)
public let imageURLs: [String]
+ /// 소비 상태. `feedback` 테이블의 read/archived 행에서 온다 (없으면 nil = 안 읽음).
+ public let state: String?
public init(
id: Int64,
@@ -35,7 +37,8 @@ public struct DashboardPost: Identifiable, Equatable, Sendable {
contentMarkdown: String? = nil,
wordCount: Int? = nil,
crawledAt: String,
- imageURLs: [String] = []
+ imageURLs: [String] = [],
+ state: String? = nil
) {
self.id = id
self.platform = platform
@@ -53,8 +56,11 @@ public struct DashboardPost: Identifiable, Equatable, Sendable {
self.wordCount = wordCount
self.crawledAt = crawledAt
self.imageURLs = imageURLs
+ self.state = state
}
+ public var isRead: Bool { state != nil }
+
public var displayTitle: String {
guard let title, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return author
diff --git a/apps/desktop/Sources/SkimDesktopCore/SkimDatabase.swift b/apps/desktop/Sources/SkimDesktopCore/SkimDatabase.swift
index 22179d6..7bcff82 100644
--- a/apps/desktop/Sources/SkimDesktopCore/SkimDatabase.swift
+++ b/apps/desktop/Sources/SkimDesktopCore/SkimDatabase.swift
@@ -258,10 +258,22 @@ public final class SkimDatabase {
offset: Int = 0,
sort: PostSort = .newest
) throws -> [DashboardPost] {
- try query(
+ // 이 앱은 파이프라인이 소유한 DB를 읽는다. `feedback`이 없는 옛 파일도 있을 수
+ // 있어서, 없으면 상태 칸을 NULL로 채운다 (prepare 단계에서 통째로 죽지 않게).
+ let stateColumn = try hasTable("feedback")
+ ? """
+ (SELECT action FROM feedback
+ WHERE feedback.post_id = posts.id
+ AND feedback.action IN ('read', 'archived')
+ ORDER BY feedback.id DESC LIMIT 1)
+ """
+ : "NULL"
+
+ return try query(
"""
- SELECT id, platform, source, external_id, author, title, content, url, timestamp,
- likes, comments, summary, content_markdown, word_count, crawled_at, extra
+ SELECT posts.id, platform, source, external_id, author, title, content, url, timestamp,
+ likes, comments, summary, content_markdown, word_count, crawled_at, extra,
+ \(stateColumn)
FROM posts
WHERE \(clause)
ORDER BY \(sort.orderClause)
@@ -285,11 +297,28 @@ public final class SkimDatabase {
contentMarkdown: text(statement, 12),
wordCount: int(statement, 13),
crawledAt: text(statement, 14) ?? "",
- imageURLs: Self.imageURLs(fromExtra: text(statement, 15))
+ imageURLs: Self.imageURLs(fromExtra: text(statement, 15)),
+ state: text(statement, 16)
)
}
}
+ /// 게시글의 소비 상태를 기록합니다. `state`가 nil이면 지웁니다(= 안 읽음).
+ ///
+ /// CLI의 `skim mark`와 같은 테이블을 쓴다. 새 컬럼을 만들지 않고 스키마만 있고
+ /// 행이 0개이던 `feedback`을 재사용한다.
+ public func setPostState(id: Int64, state: String?) throws {
+ try execute(
+ "DELETE FROM feedback WHERE post_id = ? AND action IN ('read', 'archived')",
+ bindings: [.integer(id)]
+ )
+ guard let state, state == "read" || state == "archived" else { return }
+ try execute(
+ "INSERT INTO feedback (post_id, action) VALUES (?, ?)",
+ bindings: [.integer(id), .text(state)]
+ )
+ }
+
/// extra JSON의 `images`(첨부 배열)와 `image`(og:image)를 중복 제거해 합칩니다.
static func imageURLs(fromExtra json: String?) -> [String] {
guard let json,
@@ -666,8 +695,17 @@ public final class SkimDatabase {
UNIQUE(platform, login_identifier)
);
+ CREATE TABLE IF NOT EXISTS feedback (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ post_id INTEGER NOT NULL REFERENCES posts(id),
+ action TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
CREATE INDEX IF NOT EXISTS idx_posts_platform ON posts(platform);
CREATE INDEX IF NOT EXISTS idx_posts_crawled_at ON posts(crawled_at);
+ CREATE INDEX IF NOT EXISTS idx_feedback_post_id ON feedback(post_id);
+ CREATE INDEX IF NOT EXISTS idx_feedback_action ON feedback(action);
CREATE INDEX IF NOT EXISTS idx_tracked_sources_platform ON tracked_sources(platform);
CREATE INDEX IF NOT EXISTS idx_tracked_sources_enabled ON tracked_sources(is_enabled);
CREATE INDEX IF NOT EXISTS idx_credentials_platform ON platform_credentials(platform);
diff --git a/apps/desktop/Tests/SkimDesktopCoreTests/SkimDatabaseTests.swift b/apps/desktop/Tests/SkimDesktopCoreTests/SkimDatabaseTests.swift
index 36862ee..5e35184 100644
--- a/apps/desktop/Tests/SkimDesktopCoreTests/SkimDatabaseTests.swift
+++ b/apps/desktop/Tests/SkimDesktopCoreTests/SkimDatabaseTests.swift
@@ -255,6 +255,51 @@ func searchRunsOverWholeDatabaseAndTreatsWildcardsAsLiterals() throws {
}
}
+/// 읽음 상태는 CLI(`skim mark`)와 같은 `feedback` 행을 쓴다. 새 컬럼을 만들지 않고
+/// 스키마만 있고 행이 0개이던 테이블을 재사용한다.
+@Test
+func postStateRoundTripsThroughTheFeedbackTable() throws {
+ try withFixtureDatabase { database in
+ try database.execute(
+ """
+ INSERT INTO posts (platform, author, content, crawled_at)
+ VALUES ('blogs', 'a', 'body', '2026-08-10 01:00:00');
+ """
+ )
+ let postID = try #require(try database.fetchRecentPosts(limit: 1).first).id
+
+ #expect(try database.fetchRecentPosts(limit: 1).first?.isRead == false)
+
+ try database.setPostState(id: postID, state: "read")
+ #expect(try database.fetchRecentPosts(limit: 1).first?.state == "read")
+ #expect(try database.fetchRecentPosts(limit: 1).first?.isRead == true)
+
+ // 상태는 게시글당 하나다. 바꾸면 앞의 값이 남으면 안 된다.
+ try database.setPostState(id: postID, state: "archived")
+ #expect(try database.fetchRecentPosts(limit: 1).first?.state == "archived")
+
+ try database.setPostState(id: postID, state: nil)
+ #expect(try database.fetchRecentPosts(limit: 1).first?.state == nil)
+ }
+}
+
+@Test
+func unknownPostStateIsIgnored() throws {
+ try withFixtureDatabase { database in
+ try database.execute(
+ """
+ INSERT INTO posts (platform, author, content, crawled_at)
+ VALUES ('blogs', 'a', 'body', '2026-08-10 01:00:00');
+ """
+ )
+ let postID = try #require(try database.fetchRecentPosts(limit: 1).first).id
+
+ try database.setPostState(id: postID, state: "starred")
+
+ #expect(try database.fetchRecentPosts(limit: 1).first?.state == nil)
+ }
+}
+
private func withFixtureDatabase(_ body: (SkimDatabase) throws -> Void) throws {
let directory = FileManager.default.temporaryDirectory.appending(
path: "skim-desktop-\(UUID().uuidString)",
diff --git a/docs/TODO.ko.md b/docs/TODO.ko.md
index 179420b..28a3155 100644
--- a/docs/TODO.ko.md
+++ b/docs/TODO.ko.md
@@ -6,12 +6,23 @@ Skim에 넣을 source 후보와 promotion checklist입니다. 구현 계획은 `
## 이미 포함됨
-- Communities: Hacker News, GeekNews, Product Hunt
+- Communities: Hacker News (newest + Show + Ask), Lobsters, GeekNews, Product Hunt
- Social/API: Threads, X, LinkedIn, Reddit
-- Articles: Every.to, `PERSONAL_BLOGS`의 personal blogs
+- Social/공개: Bluesky (`BLUESKY_ACCOUNTS`, 로그인 불필요)
+- Articles: Every.to, `PERSONAL_BLOGS`의 블로그와 뉴스레터
- Video: `YOUTUBE_CHANNELS`의 YouTube channels
-- Papers: Hugging Face Daily Papers, arXiv cs.AI
-- AI labs: OpenAI, Anthropic, LangChain
+- Papers: Hugging Face Daily Papers, arXiv (cs.AI, cs.CL, cs.LG, cs.CV)
+- AI labs: OpenAI, Anthropic, LangChain, Google DeepMind, Google Research, Hugging Face, Mistral
+
+## 등록하지 않은 소스
+
+실측해서 떨어진 것들. 손으로 다시 확인하지 않도록 남긴다 (2026-08-10):
+
+- Meta AI `https://ai.meta.com/blog/rss/` — HTTP 400.
+- DeepSeek `https://api.deepseek.com/rss` — HTTP 401.
+- 요즘IT `https://yozm.wishket.com/magazine/feed/` — 200에 엔트리 30건이 오는데
+ 발행일 필드가 하나도 없다. `fetch_feed`가 날짜 없는 엔트리를 버리므로 등록해도
+ 매번 0건인데 겉보기엔 멀쩡하다. 글 페이지에서 날짜를 읽는 전용 파서가 필요하다.
## 후보 계정
diff --git a/docs/TODO.md b/docs/TODO.md
index 62e2f7f..3280cdc 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -6,12 +6,13 @@ Candidate sources and promotion checklist for Skim. Keep implementation plans un
## Already Covered
-- Communities: Hacker News, GeekNews, Product Hunt
+- Communities: Hacker News (newest + Show + Ask), Lobsters, GeekNews, Product Hunt
- Social/API: Threads, X, LinkedIn, Reddit
-- Articles: Every.to, personal blogs in `PERSONAL_BLOGS`
+- Social/public: Bluesky (`BLUESKY_ACCOUNTS`, no login required)
+- Articles: Every.to, blogs and newsletters in `PERSONAL_BLOGS`
- Video: YouTube channels in `YOUTUBE_CHANNELS`
-- Papers: Hugging Face Daily Papers, arXiv cs.AI
-- AI labs: OpenAI, Anthropic, LangChain
+- Papers: Hugging Face Daily Papers, arXiv (cs.AI, cs.CL, cs.LG, cs.CV)
+- AI labs: OpenAI, Anthropic, LangChain, Google DeepMind, Google Research, Hugging Face, Mistral
## Candidate Accounts
@@ -43,6 +44,16 @@ Candidate sources and promotion checklist for Skim. Keep implementation plans un
- Google AI blogs and research updates
+## Rejected Sources
+
+Probed and failed, so nobody re-checks them by hand (2026-08-10):
+
+- Meta AI `https://ai.meta.com/blog/rss/` — HTTP 400.
+- DeepSeek `https://api.deepseek.com/rss` — HTTP 401.
+- 요즘IT `https://yozm.wishket.com/magazine/feed/` — HTTP 200 with 30 entries, but no
+ date field on any entry. `fetch_feed` drops undated entries, so registering it collects
+ nothing while looking healthy. Needs a custom parser that reads dates off the article page.
+
## Retired Sources
- `every.to/Guides` — `/guides/feed` returns HTTP 500 with no alternate feed or sitemap (checked 2026-08-09). The `/guides` page itself is alive, so it can return as a `scrape` source if it becomes worth a custom index parser. Last collected 2026-06-02.
diff --git a/packages/skim-cli/src/skim_cli/cli.py b/packages/skim-cli/src/skim_cli/cli.py
index b0a4c35..8fcb7dd 100644
--- a/packages/skim-cli/src/skim_cli/cli.py
+++ b/packages/skim-cli/src/skim_cli/cli.py
@@ -4,6 +4,7 @@
import csv
import json
import re
+import shutil
import sys
from dataclasses import asdict
from datetime import datetime, timedelta, timezone
@@ -17,14 +18,20 @@
from skim_core.crawlers.auth.cdp import login as cdp_login
from skim_core.db import (
DB_PATH,
+ backfill_canonical_urls,
+ backup_db,
+ canonical_body,
+ check_integrity,
finish_run,
get_connection,
init_db,
list_tracked_sources,
migrate_canonical_body,
+ POST_STATES,
platforms_with_recent_posts,
save_posts,
save_run,
+ set_post_state,
update_run_progress,
upsert_tracked_source,
)
@@ -32,7 +39,12 @@
from skim_core.paths import DATA_DIR
from skim_core.research.refresh import run_research
from skim_core.research.search import search_posts
-from skim_core.research.serializer import build_response, utc_now_iso
+from skim_core.research.serializer import (
+ ALLOWED_POST_FIELDS,
+ build_response,
+ shape_posts,
+ utc_now_iso,
+)
from skim_core.research.types import SearchStats
from skim_core.source_health import scan_source_health
from skim_core.source_probe import format_probe_result, probe_source
@@ -50,12 +62,23 @@
FEED_PLATFORMS = set(REGISTRY.keys()) - SNS_PLATFORMS
# 소스가 노출하는 발행일이 실제 게시 시점보다 뒤처지면 좁은 창에서는 전량 걸러진다.
-# huggingface daily papers는 arXiv 발행일을 그대로 싣기 때문에 2일 이상 밀려 있다.
# --days를 명시해도 이 값이 바닥으로 깔린다. 일일 배치가 `crawl all --days 1`로
# 돌기 때문에, 기본값에만 반영하면 정작 배치에서는 계속 0건이 된다.
-# arxiv는 요일 기반 규칙을 따로 쓰므로 여기 넣지 않는다.
+# huggingface: 주말에 큐레이션을 쉬어서 월요일에는 금요일 목록이 최신이다.
MIN_LOOKBACK_DAYS = {"huggingface": 3}
+
+def min_lookback_days(platform: str, now: datetime) -> int:
+ """플랫폼이 최소로 필요한 조회 창(일). --days로 더 좁혀도 이 밑으로 안 내려간다.
+
+ 좁은 창에서 항상 0건이 나오는 소스는 회귀 경고를 매일 울려, 진짜 고장 신호를
+ 덮는다. arXiv는 주말에 announce하지 않으므로 월/토/일은 금요일 몫까지 거슬러 본다.
+ """
+ if platform == "arxiv":
+ return 4 if now.weekday() in (0, 5, 6) else 2
+ return MIN_LOOKBACK_DAYS.get(platform, 0)
+
+
# 이 기간 안에 유입 이력이 있던 플랫폼이 0건이면 회귀로 본다.
REGRESSION_LOOKBACK_DAYS = 14
@@ -208,16 +231,9 @@ def crawl( # noqa: C901 — CLI 진입점으로 플랫폼별 분기가 불가
options["count"] = count if count is not None else SNS_DEFAULT_COUNT
else:
# Feed: since 기반 (기본 전날 0시부터)
- if days is not None:
- d = days
- elif platform == "arxiv":
- # arXiv는 주말에 새 논문을 게시하지 않으므로 월/토/일은 4일 전까지 확인
- weekday = now.weekday() # 0=Mon ... 6=Sun
- d = 4 if weekday in (0, 5, 6) else 2
- else:
- d = 1
+ d = days if days is not None else 1
# 발행일이 밀린 소스는 사용자가 창을 좁혀도 최소 폭을 보장한다.
- d = max(d, MIN_LOOKBACK_DAYS.get(platform, 0))
+ d = max(d, min_lookback_days(platform, now))
since = (now - timedelta(days=d)).replace(
hour=0, minute=0, second=0, microsecond=0
)
@@ -259,7 +275,10 @@ def crawl( # noqa: C901 — CLI 진입점으로 플랫폼별 분기가 불가
# 개별 항목의 enrichment 실패는 로그 한 줄로 흘러가고 크롤은 성공으로
# 끝난다. playwright 미설치처럼 전 항목에 영향을 주는 고장도 그래서
# 며칠씩 묻힌다. 플랫폼별로 세어 마지막에 한 번에 보여준다.
- thin = sum(1 for p in posts if not (p.content_markdown or "").strip())
+ # save_posts와 같은 판정 함수를 써야 한다. API형 4종은 본문이 content로
+ # 와서 저장 직전에 승격되므로, 승격 전 content_markdown만 보면 정상
+ # 저장된 회차가 전량 실패로 잡힌다.
+ thin = sum(1 for p in posts if not canonical_body(p, platform))
if thin:
thin_platforms[platform] = (thin, len(posts))
@@ -310,7 +329,8 @@ def crawl( # noqa: C901 — CLI 진입점으로 플랫폼별 분기가 불가
if thin_platforms:
detail = ", ".join(
- f"{name} {miss}/{total}" for name, (miss, total) in sorted(thin_platforms.items())
+ f"{name} {miss}/{total}"
+ for name, (miss, total) in sorted(thin_platforms.items())
)
typer.echo(f"\n[!] 본문 추출 실패: {detail}")
typer.echo(" 반복되면 `uv run skim doctor`로 추출 환경을 점검하세요.")
@@ -427,11 +447,53 @@ def youtube_transcribe(video: str = typer.Argument(..., help="영상 URL 또는
@app.command()
def migrate(db: Optional[Path] = typer.Option(None, "--db", help="SQLite DB 경로")):
"""기존 데이터를 정본 본문 모델로 이행합니다 (content_markdown 통일). 멱등."""
+ init_db(db)
result = migrate_canonical_body(db)
typer.echo(
f"API 본문 승격: {result['api_promoted']}건, "
f"Feed content 정리: {result['feed_content_cleared']}건"
)
+ filled = backfill_canonical_urls(db)
+ typer.echo(f"canonical_url 채움: {filled}건")
+
+
+@app.command()
+def backup(
+ db: Optional[Path] = typer.Option(None, "--db", help="SQLite DB 경로"),
+ dest_dir: Optional[Path] = typer.Option(
+ None, "--dest", help="백업 디렉터리 (기본 data/backups)"
+ ),
+ keep: int = typer.Option(3, "--keep", help="보관할 백업 개수"),
+):
+ """DB를 온라인 백업하고 오래된 백업을 정리합니다.
+
+ 649MB 단일 파일에 3년치가 사본 없이 들어 있었다. 파일 복사와 달리 WAL이
+ 진행 중이어도 일관된 스냅샷이 나오므로 크롤과 겹쳐도 안전하다.
+ """
+ db_path = _db_or_default(db)
+ if not db_path.exists():
+ typer.echo(f"[skim] DB가 없습니다: {db_path}", err=True)
+ raise typer.Exit(1)
+
+ target_dir = dest_dir or (db_path.parent / "backups")
+ stamp = datetime.now(KST).strftime("%Y%m%d_%H%M%S")
+ dest = target_dir / f"{db_path.stem}.{stamp}.db"
+ backup_db(dest, db_path)
+ size_mb = dest.stat().st_size / (1024 * 1024)
+ typer.echo(f"백업: {dest} ({size_mb:.1f}MB)")
+
+ if keep > 0:
+ existing = sorted(
+ target_dir.glob(f"{db_path.stem}.*.db"), key=lambda p: p.name, reverse=True
+ )
+ for stale in existing[keep:]:
+ stale.unlink()
+ typer.echo(f"오래된 백업 삭제: {stale.name}")
+
+ integrity = check_integrity(db_path)
+ typer.echo(f"integrity: {integrity}")
+ if integrity != "ok":
+ raise typer.Exit(1)
@app.command()
@@ -441,6 +503,9 @@ def doctor(
),
db: Optional[Path] = typer.Option(None, "--db", help="SQLite DB 경로"),
emit: str = typer.Option("summary", "--emit", help="summary|json"),
+ strict: bool = typer.Option(
+ False, "--strict", help="warning이 하나라도 있으면 exit 1 (cron 연동용)"
+ ),
):
"""DB, 수집 run, session 상태를 점검합니다."""
_validate_platform(platform)
@@ -509,7 +574,8 @@ def doctor(
FROM runs
ORDER BY id DESC
LIMIT 10
- """).fetchall()]
+ """).fetchall()
+ ]
# 본문 정본(content_markdown)이 빈 최근 유입분. summary 폴백까지 세는
# 위 missing_text와 달리 데이터 계약을 그대로 본다. youtube 목록 행은
# 본문 없이 저장되는 것이 계약이라 뺀다.
@@ -536,7 +602,9 @@ def doctor(
report["extractor"] = _playwright_status()
if not report["extractor"]["ok"]:
- report["warnings"].append(f"playwright unavailable: {report['extractor']['detail']}")
+ report["warnings"].append(
+ f"playwright unavailable: {report['extractor']['detail']}"
+ )
try:
health = scan_source_health(db_path)
except Exception as exc: # pragma: no cover - defensive report path
@@ -548,6 +616,21 @@ def doctor(
for issue in health:
report["warnings"].append(f"{issue['source']}: {issue['detail']}")
+ # 외부 CLI 의존: 없으면 본문/자막 추출이 통째로 죽는데 크롤은 성공으로 끝난다.
+ # launchd는 셸 프로필을 안 읽어 PATH가 달라지므로 크론에서 특히 잘 사라진다.
+ report["tools"] = {
+ name: shutil.which(name) or "" for name in ("yt-dlp", "bunx", "uv")
+ }
+ for name, found in report["tools"].items():
+ if not found:
+ report["warnings"].append(f"{name} not on PATH")
+
+ if report["db_exists"]:
+ integrity = check_integrity(db_path)
+ report["integrity"] = integrity
+ if integrity != "ok":
+ report["warnings"].append(f"database integrity: {integrity}")
+
if platform and not report["platforms"]:
report["warnings"].append(f"no posts found for {platform}")
if any(
@@ -556,6 +639,8 @@ def doctor(
):
report["warnings"].append("recent runs need attention")
_emit_doctor(report, emit)
+ if strict and report["warnings"]:
+ raise typer.Exit(1)
def _playwright_status() -> dict:
@@ -578,7 +663,9 @@ def _playwright_status() -> dict:
browser = play.chromium.launch(headless=True)
browser.close()
except Exception as exc: # pragma: no cover - 환경 의존
- first_line = str(exc).strip().splitlines()[0] if str(exc).strip() else type(exc).__name__
+ first_line = (
+ str(exc).strip().splitlines()[0] if str(exc).strip() else type(exc).__name__
+ )
return {
"ok": False,
"detail": f"{first_line[:150]} (uv run playwright install)",
@@ -608,6 +695,11 @@ def _emit_doctor(report: dict, emit: str) -> None:
if report.get("extractor"):
state = "ok" if report["extractor"]["ok"] else "unavailable"
typer.echo(f"extractor: playwright {state} - {report['extractor']['detail']}")
+ if report.get("tools"):
+ missing = [name for name, found in report["tools"].items() if not found]
+ typer.echo(f"tools: {'ok' if not missing else 'missing ' + ', '.join(missing)}")
+ if report.get("integrity"):
+ typer.echo(f"integrity: {report['integrity']}")
if report["sessions"]:
present = [s["platform"] for s in report["sessions"] if s["exists"]]
typer.echo(f"sessions: {', '.join(present) if present else 'none'}")
@@ -779,8 +871,29 @@ def bundle(
output_dir: Optional[Path] = typer.Option(
None, "--output-dir", "-o", help="bundle 디렉터리"
),
+ fields: Optional[str] = typer.Option(
+ None, "--fields", help="쉼표 구분 출력 필드 (기본 전체)"
+ ),
+ max_chars: Optional[int] = typer.Option(
+ None, "--max-chars", help="본문 필드 최대 길이"
+ ),
+ group_by: Optional[str] = typer.Option(
+ None, "--group-by", help="summary.md 묶음 기준: platform|source|date"
+ ),
):
- """research/source-inventory handoff bundle을 생성합니다."""
+ """research/source-inventory handoff bundle을 생성합니다.
+
+ topic 없이 부르면 최근 N일 게시글을 본문까지 담아 떨군다. 예전에는 posts가 빈
+ 배열이라, 다이제스트 같은 소비자가 CLI를 우회해 sqlite3로 DB를 직접 열어야 했다.
+ """
+ if group_by and group_by not in _BUNDLE_GROUP_KEYS:
+ typer.echo(
+ f"[skim] invalid --group-by: {group_by!r}. "
+ f"choose from {sorted(_BUNDLE_GROUP_KEYS)}",
+ err=True,
+ )
+ raise typer.Exit(2)
+ field_list = _parse_fields(fields)
db_path = _db_or_default(db)
if not db_path.exists():
typer.echo(f"missing database: {db_path}", err=True)
@@ -825,18 +938,26 @@ def bundle(
warnings=warnings,
)
else:
- response = {
- "topic": None,
- "days": days,
- "sources_requested": source_list,
- "posts": [],
- "stats": {"total": len(inventory)},
- "warnings": [],
- }
+ since_utc_iso = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
+ rows = _recent_posts(db_path, days, platforms, limit)
+ response = build_response(
+ topic=None,
+ tokens=[],
+ date_range={"from": since_utc_iso, "to": utc_now_iso()},
+ sources_requested=source_list,
+ posts=rows,
+ search_stats=SearchStats(rows_scanned=len(rows), rows_returned=len(rows)),
+ days_requested=days,
+ warnings=[],
+ )
+
+ shape_posts(response, fields=field_list, max_chars=max_chars)
results_path.write_text(
json.dumps(response, ensure_ascii=False, indent=2), encoding="utf-8"
)
- summary_path.write_text(_bundle_summary(response, inventory_path), encoding="utf-8")
+ summary_path.write_text(
+ _bundle_summary(response, inventory_path, group_by), encoding="utf-8"
+ )
proof_path.write_text(
"\n".join(
[
@@ -919,20 +1040,115 @@ def _write_tsv(path: Path, rows: list[dict]) -> None:
writer.writerows(rows)
-def _bundle_summary(response: dict, inventory_path: Path) -> str:
+_BUNDLE_GROUP_KEYS = {"platform", "source", "date"}
+
+
+def _group_key(post: dict, group_by: str) -> str:
+ if group_by == "date":
+ return (post.get("timestamp") or "")[:10] or "(no date)"
+ return post.get(group_by) or f"(no {group_by})"
+
+
+def _bundle_summary(
+ response: dict, inventory_path: Path, group_by: Optional[str] = None
+) -> str:
stats = response.get("stats", {})
lines = [
"# Skim Bundle",
"",
- f"- topic: {response.get('topic') or '(inventory)'}",
+ f"- topic: {response.get('topic') or '(recent)'}",
f"- total results: {stats.get('total', 0)}",
f"- source inventory: {inventory_path}",
]
if response.get("warnings"):
lines.append(f"- warnings: {', '.join(response['warnings'])}")
+
+ if group_by:
+ grouped: dict[str, list[dict]] = {}
+ for post in response.get("posts") or []:
+ grouped.setdefault(_group_key(post, group_by), []).append(post)
+ lines.append("")
+ lines.append(f"## Grouped by {group_by}")
+ for key in sorted(grouped):
+ lines.append("")
+ lines.append(f"### {key} ({len(grouped[key])})")
+ for post in grouped[key]:
+ title = (post.get("title") or post.get("content") or "").strip()
+ title = re.sub(r"\s+", " ", title)[:100] or "(untitled)"
+ url = post.get("url") or ""
+ lines.append(f"- [{title}]({url})" if url else f"- {title}")
return "\n".join(lines) + "\n"
+def _recent_posts(
+ db_path: Path,
+ days: int,
+ platforms: Optional[list[str]],
+ limit: int,
+ unread_only: bool = False,
+) -> list[dict]:
+ """topic 없이 최근 게시글을 본문까지 읽는다.
+
+ `_recent_inventory`는 목록용이라 본문을 안 싣는다. 이 함수가 없어서 소비자가
+ CLI를 우회해 sqlite3로 DB를 직접 열고 있었다.
+ """
+ where = ["crawled_at >= ?"]
+ params: list = [_cutoff(days)]
+ if platforms:
+ where.append(f"platform IN ({','.join('?' * len(platforms))})")
+ params.extend(platforms)
+ if unread_only:
+ where.append(
+ "posts.id NOT IN (SELECT post_id FROM feedback "
+ f"WHERE action IN ({','.join('?' * len(POST_STATES))}))"
+ )
+ params.extend(POST_STATES)
+ params.append(limit)
+
+ conn = get_connection(db_path)
+ try:
+ rows = conn.execute(
+ f"""SELECT posts.id, {", ".join(ALLOWED_POST_FIELDS[:-2])}
+ FROM posts
+ WHERE {" AND ".join(where)}
+ ORDER BY COALESCE(NULLIF(timestamp, ''), crawled_at) DESC
+ LIMIT ?""",
+ params,
+ ).fetchall()
+ finally:
+ conn.close()
+ return [dict(row) for row in rows]
+
+
+@app.command("mark")
+def mark_posts(
+ post_ids: List[int] = typer.Argument(..., help="posts.id (공백 구분)"),
+ state: str = typer.Option(
+ "read", "--state", help="read | archived | unread (unread는 상태를 지운다)"
+ ),
+ db: Optional[Path] = typer.Option(None, "--db", help="SQLite DB 경로"),
+) -> None:
+ """게시글의 소비 상태를 기록합니다.
+
+ 하루 200건대가 들어오는데 어디까지 봤는지 표시할 데가 없었다. `feedback`
+ 테이블이 스키마만 있고 행이 0개였어서 새 컬럼 대신 그걸 쓴다.
+ """
+ if state not in (*POST_STATES, "unread"):
+ typer.echo(
+ f"[skim] invalid --state: {state!r}. "
+ f"choose from {[*POST_STATES, 'unread']}",
+ err=True,
+ )
+ raise typer.Exit(2)
+
+ db_path = _db_or_default(db)
+ init_db(db_path)
+ target = None if state == "unread" else state
+ for post_id in post_ids:
+ set_post_state(post_id, target, db_path)
+ typer.echo(f"{len(post_ids)}건 -> {state}")
+
+
VALID_REFRESH_MODES = {"auto", "never", "force"}
VALID_EMIT_MODES = {"json", "jsonl", "summary"}
@@ -951,11 +1167,26 @@ def research(
"--refresh",
help="auto|never|force (Phase 1 에서는 never 고정)",
),
+ fields: Optional[str] = typer.Option(
+ None, "--fields", help="쉼표 구분 출력 필드 (기본 전체)"
+ ),
+ max_chars: Optional[int] = typer.Option(
+ None,
+ "--max-chars",
+ help="본문 필드 최대 길이. 자르면 truncated 플래그가 붙는다",
+ ),
):
"""topic 으로 posts 를 필터링해 구조화 JSON 으로 반환.
Phase 1 단독 실행: `--refresh never`. auto/force 는 Phase 2 에서 활성화.
+
+ 본문 전문이 필요 없으면 출력을 줄인다. 기본 `--emit json`은 content_markdown을
+ 통째로 실어서 실측 16.6MB가 나온다:
+
+ skim research "agent" --fields platform,title,url,timestamp
+ skim research "agent" --max-chars 2000
"""
+ field_list = _parse_fields(fields)
if not topic.strip():
typer.echo("Usage: skim research TOPIC [OPTIONS]", err=True)
raise typer.Exit(code=2)
@@ -1008,7 +1239,7 @@ def research(
days_requested=days,
warnings=["no searchable tokens in topic"],
)
- _emit_response(response, emit)
+ _emit_response(response, emit, field_list, max_chars)
return
sources_for_search_resolved = sources_for_search or list(REGISTRY.keys())
@@ -1041,7 +1272,7 @@ def research(
)
if msg not in response["warnings"]:
response["warnings"].append(msg)
- _emit_response(response, emit)
+ _emit_response(response, emit, field_list, max_chars)
return
# refresh == 'never' 경로 (Phase 1 동작)
@@ -1083,10 +1314,30 @@ def research(
days_requested=days,
warnings=warnings,
)
- _emit_response(response, emit)
+ _emit_response(response, emit, field_list, max_chars)
+
+
+def _parse_fields(raw: Optional[str]) -> Optional[List[str]]:
+ """`--fields` 값을 검증해 리스트로. 없으면 None (전체 필드)."""
+ if not raw:
+ return None
+ names = [name.strip() for name in raw.split(",") if name.strip()]
+ extra_ok = {"truncated", "content_markdown_chars"}
+ unknown = [n for n in names if n not in ALLOWED_POST_FIELDS and n not in extra_ok]
+ if unknown:
+ typer.echo(f"[skim] unknown fields: {unknown}", err=True)
+ typer.echo(f"supported: {', '.join(ALLOWED_POST_FIELDS)}", err=True)
+ raise typer.Exit(code=2)
+ return names
-def _emit_response(response: dict, emit: str) -> None:
+def _emit_response(
+ response: dict,
+ emit: str,
+ fields: Optional[List[str]] = None,
+ max_chars: Optional[int] = None,
+) -> None:
+ shape_posts(response, fields=fields, max_chars=max_chars)
if emit == "json":
typer.echo(json.dumps(response, ensure_ascii=False, indent=2))
elif emit == "jsonl":
@@ -1098,6 +1349,91 @@ def _emit_response(response: dict, emit: str) -> None:
)
+def _md_filename(post: dict, index: int) -> str:
+ """게시글 하나의 파일명. 같은 날 같은 제목이 겹쳐도 안 덮어쓰게 번호를 붙인다."""
+ stamp = (post.get("timestamp") or "")[:10] or "undated"
+ title = _slug(post.get("title") or post.get("content") or "")[:60] or "post"
+ return f"{stamp}-{post.get('platform') or 'skim'}-{title}-{index:03d}.md"
+
+
+def _post_to_markdown(post: dict) -> str:
+ """YAML frontmatter + 정본 본문. Obsidian이 그대로 읽는 형식."""
+
+ def esc(value) -> str:
+ return json.dumps(value if value is not None else "", ensure_ascii=False)
+
+ front = [
+ "---",
+ f"title: {esc(post.get('title') or '')}",
+ f"platform: {esc(post.get('platform') or '')}",
+ f"source: {esc(post.get('source') or '')}",
+ f"author: {esc(post.get('author') or '')}",
+ f"url: {esc(post.get('url') or '')}",
+ f"timestamp: {esc(post.get('timestamp') or '')}",
+ f"word_count: {post.get('word_count') or 0}",
+ "---",
+ "",
+ ]
+ body = (post.get("content_markdown") or post.get("content") or "").strip()
+ title = (post.get("title") or "").strip()
+ if title:
+ front.append(f"# {title}")
+ front.append("")
+ return "\n".join(front) + body + "\n"
+
+
+@app.command("export")
+def export_posts(
+ out_dir: Path = typer.Argument(..., help="출력 디렉터리"),
+ days: int = typer.Option(7, "--days", help="최근 N일"),
+ platform: Optional[str] = typer.Option(
+ None, "--platform", "-p", help="플랫폼으로 좁힌다"
+ ),
+ limit: int = typer.Option(200, "--limit", help="최대 건수"),
+ db: Optional[Path] = typer.Option(None, "--db", help="SQLite DB 경로"),
+ fmt: str = typer.Option("md", "--format", help="md 또는 json"),
+ unread: bool = typer.Option(False, "--unread", help="아직 안 읽은 글만"),
+) -> None:
+ """정본 본문을 파일로 꺼낸다. 본문이 DB에만 있어 손으로 복사하던 자리다."""
+ _validate_platform(platform)
+ if fmt not in {"md", "json"}:
+ typer.echo("[skim] --format 은 md 또는 json", err=True)
+ raise typer.Exit(2)
+
+ db_path = _db_or_default(db)
+ if not db_path.exists():
+ typer.echo(f"missing database: {db_path}", err=True)
+ raise typer.Exit(1)
+
+ rows = _recent_posts(
+ db_path, days, [platform] if platform else None, limit, unread_only=unread
+ )
+ if not rows:
+ typer.echo("내보낼 게시글이 없습니다.")
+ return
+
+ out_dir.mkdir(parents=True, exist_ok=True)
+ if fmt == "json":
+ target = out_dir / f"skim-export-{datetime.now(KST):%Y%m%d_%H%M%S}.json"
+ target.write_text(
+ json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ typer.echo(f"{len(rows)}건 -> {target}")
+ return
+
+ written = 0
+ for index, post in enumerate(rows):
+ body = (post.get("content_markdown") or post.get("content") or "").strip()
+ if not body:
+ # 본문 없는 행까지 파일로 만들면 vault가 빈 노트로 오염된다.
+ continue
+ (out_dir / _md_filename(post, index)).write_text(
+ _post_to_markdown(post), encoding="utf-8"
+ )
+ written += 1
+ typer.echo(f"{written}건 -> {out_dir} (본문 없는 {len(rows) - written}건 제외)")
+
+
@source_app.command()
def probe(
urls: List[str] = typer.Argument(..., help="진단할 사이트 또는 피드 URL"),
@@ -1231,6 +1567,146 @@ def _sources_markdown(rows: List[dict]) -> str:
return "\n".join(lines)
+def _sources_opml(rows: List[dict]) -> str:
+ """tracked_sources를 OPML로. 다른 리더가 그대로 읽는 교환 포맷이다."""
+ from xml.sax.saxutils import quoteattr # pylint: disable=import-outside-toplevel
+
+ lines = [
+ '',
+ '',
+ " ",
+ " Skim sources",
+ f" {utc_now_iso()}",
+ " ",
+ " ",
+ ]
+ by_platform: dict[str, List[dict]] = {}
+ for row in rows:
+ by_platform.setdefault(row["platform"], []).append(row)
+
+ for platform in sorted(by_platform):
+ lines.append(f" ")
+ for row in by_platform[platform]:
+ feed = row.get("feed_url") or row.get("handle_or_url") or ""
+ if not feed:
+ continue
+ lines.append(
+ ' "
+ )
+ lines.append(" ")
+ lines += [" ", "", ""]
+ return "\n".join(lines)
+
+
+@source_app.command("export")
+def source_export(
+ out: Optional[Path] = typer.Option(None, "--out", help="파일 경로 (기본 stdout)"),
+ platform: Optional[str] = typer.Option(
+ None, "--platform", help="플랫폼으로 좁힌다"
+ ),
+) -> None:
+ """등록된 소스를 OPML로 내보낸다."""
+ _validate_platform(platform)
+ init_db()
+ platforms = [platform] if platform else sorted(REGISTRY.keys())
+ rows: List[dict] = []
+ for name in platforms:
+ for row in list_tracked_sources(name, enabled_only=False):
+ rows.append({"platform": name, **row})
+
+ opml = _sources_opml(rows)
+ if out:
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(opml, encoding="utf-8")
+ typer.echo(f"{len(rows)}개 소스 -> {out}")
+ else:
+ typer.echo(opml)
+
+
+def parse_opml(text: str) -> List[dict]:
+ """OPML에서 (name, feed_url, site_url)을 뽑는다. 중첩 outline도 훑는다.
+
+ DOCTYPE이 있으면 파싱 전에 거절한다. 사용자가 준 파일이라 신뢰 경계이고,
+ expat은 내부 엔티티를 그대로 펼쳐 billion-laughs로 메모리를 태울 수 있다.
+ OPML 스펙에는 DTD가 필요 없으므로 이 거절이 정상 파일을 막지 않는다.
+ (외부 엔티티는 stdlib ElementTree가 애초에 해석하지 않는다.)
+ """
+ # pylint: disable=import-outside-toplevel
+ from xml.etree import ElementTree
+
+ if re.search(r" None:
+ """OPML을 tracked_sources로 가져온다 (멱등).
+
+ 내보내기만 있고 되읽는 경로가 없어서, DB가 깨지면 등록 소스를 손으로 다시
+ 넣어야 했다. probe는 돌리지 않는다 (파일이 이미 피드 주소를 갖고 있다).
+ tier는 비워두므로 등록 후 `skim source refresh --all`로 관측한다.
+ """
+ if not path.exists():
+ typer.echo(f"파일이 없습니다: {path}", err=True)
+ raise typer.Exit(1)
+ try:
+ entries = parse_opml(path.read_text(encoding="utf-8"))
+ except Exception as exc: # noqa: BLE001 - 사용자 파일이라 어떤 형식이든 올 수 있다
+ typer.echo(f"OPML 파싱 실패: {exc}", err=True)
+ raise typer.Exit(1) from exc
+
+ if not entries:
+ typer.echo("가져올 피드가 없습니다 (xmlUrl 속성이 있는 outline이 없음).")
+ return
+
+ typer.echo(f"[{platform}] 가져올 소스 {len(entries)}개:")
+ for row in entries:
+ typer.echo(f" {row['name']} {row['feed_url']}")
+ if dry_run:
+ return
+
+ init_db()
+ created = 0
+ for row in entries:
+ if upsert_tracked_source(
+ platform=platform,
+ canonical_id=row["site_url"],
+ display_name=row["name"],
+ source_type="feed",
+ handle_or_url=row["site_url"],
+ feed_url=row["feed_url"],
+ ):
+ created += 1
+ typer.echo(f"신규 {created}개, 갱신 {len(entries) - created}개")
+ typer.echo("tier 관측: uv run skim source refresh --all")
+
+
@source_app.command("sync")
def source_sync(
dry_run: bool = typer.Option(
diff --git a/packages/skim-core/src/skim_core/crawlers/__init__.py b/packages/skim-core/src/skim_core/crawlers/__init__.py
index e2d5656..7e9beaf 100644
--- a/packages/skim-core/src/skim_core/crawlers/__init__.py
+++ b/packages/skim-core/src/skim_core/crawlers/__init__.py
@@ -13,10 +13,12 @@
from .feed.ailabs import AILabsCrawler
from .feed.arxiv import ArxivCrawler
from .feed.blogs import BlogsCrawler
+from .feed.bluesky import BlueskyCrawler
from .feed.everyto import EveryToCrawler
from .feed.geeknews import GeekNewsCrawler
from .feed.hackernews import HackerNewsCrawler
from .feed.huggingface import HuggingFaceCrawler
+from .feed.lobsters import LobstersCrawler
from .feed.producthunt import ProductHuntCrawler
from .feed.youtube import YouTubeCrawler
@@ -26,7 +28,9 @@
"x": XAPICrawler,
"reddit": RedditAPICrawler,
"hackernews": HackerNewsCrawler,
+ "lobsters": LobstersCrawler,
"geeknews": GeekNewsCrawler,
+ "bluesky": BlueskyCrawler,
"youtube": YouTubeCrawler,
"producthunt": ProductHuntCrawler,
"arxiv": ArxivCrawler,
diff --git a/packages/skim-core/src/skim_core/crawlers/api/linkedin.py b/packages/skim-core/src/skim_core/crawlers/api/linkedin.py
index 0e5e3fd..ac0886c 100644
--- a/packages/skim-core/src/skim_core/crawlers/api/linkedin.py
+++ b/packages/skim-core/src/skim_core/crawlers/api/linkedin.py
@@ -8,6 +8,7 @@
import json
import re
+import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, List, Optional
@@ -24,6 +25,8 @@
VOYAGER_FEED_URL = f"{LINKEDIN_BASE_URL}/voyager/api/feed/updatesV2"
VOYAGER_COMMENTS_URL = f"{LINKEDIN_BASE_URL}/voyager/api/feed/comments"
MAX_COMMENTS = 15
+# 댓글은 게시글당 요청 1건이다. 호스트별 간격을 둬서 연속 호출로 눈에 띄지 않게 한다.
+COMMENT_REQUEST_INTERVAL_SECONDS = 1.0
LINKEDIN_USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
@@ -106,8 +109,20 @@ async def crawl(self, **options) -> List[Post]:
def attach_comments(self, posts: List[Post]) -> None:
"""게시글별 댓글을 정본 본문 뒤에 잇는다. 게시글당 요청 1건이 늘어난다."""
failures = 0
- for post in posts:
- section = self.fetch_comment_section(post.external_id)
+ for index, post in enumerate(posts):
+ if (post.comments or 0) < 1:
+ continue
+ # 게시글당 요청 1건이라 호스트별 간격을 둔다 (reddit과 같은 패턴).
+ if index:
+ time.sleep(COMMENT_REQUEST_INTERVAL_SECONDS)
+ # HTTP 실패는 fetch_comment_section 안에서 None이 된다. 여기서 잡는 건
+ # Voyager 응답 구조가 바뀌었을 때의 파싱 실패다.
+ try:
+ section = self.fetch_comment_section(post.external_id)
+ except Exception as exc: # noqa: BLE001 - 댓글 실패가 게시글 저장을 막지 않는다
+ failures += 1
+ typer.echo(f" [!] LinkedIn 댓글 파싱 실패: {exc}")
+ continue
if section is None and post.external_id:
failures += 1
continue
diff --git a/packages/skim-core/src/skim_core/crawlers/api/reddit.py b/packages/skim-core/src/skim_core/crawlers/api/reddit.py
index bbcf5dc..bf8eb41 100644
--- a/packages/skim-core/src/skim_core/crawlers/api/reddit.py
+++ b/packages/skim-core/src/skim_core/crawlers/api/reddit.py
@@ -154,10 +154,23 @@ def attach_comments(self, posts: List[Post]) -> None:
f"(연속 {consecutive}건 실패, 남은 {len(posts) - index}건은 본문만 저장)"
)
break
+ # 댓글이 0건인 글은 조회해봐야 None이 돌아온다. 그 None이 HTTP 실패와
+ # 구분되지 않아서, 조용한 서브레딧에서 0건 글 3개가 연속되면 남은
+ # 게시글 전체의 댓글 수집이 중단됐다. 링크 게시물은 댓글이 사실상 본문이다.
+ if (post.comments or 0) < 1:
+ continue
if index:
time.sleep(COMMENT_REQUEST_INTERVAL_SECONDS)
- section = self.fetch_comment_section(post.url)
+ # HTTP 실패는 fetch_comment_section 안에서 None이 된다. 여기서 잡는 건
+ # 응답 구조가 바뀌었을 때의 파싱 실패다.
+ try:
+ section = self.fetch_comment_section(post.url)
+ except Exception as exc: # noqa: BLE001 - 댓글 실패가 게시글 저장을 막지 않는다
+ typer.echo(f" [!] Reddit 댓글 파싱 실패: {exc}")
+ failures += 1
+ consecutive += 1
+ continue
if section is None and post.url:
failures += 1
consecutive += 1
diff --git a/packages/skim-core/src/skim_core/crawlers/api/threads.py b/packages/skim-core/src/skim_core/crawlers/api/threads.py
index cc858c2..2896f70 100644
--- a/packages/skim-core/src/skim_core/crawlers/api/threads.py
+++ b/packages/skim-core/src/skim_core/crawlers/api/threads.py
@@ -189,15 +189,27 @@ def attach_replies(self, posts: List[Post]) -> None:
답글이 있다고 보고된 게시물은 전부 조회한다(게시물당 요청 1건, 실측 1~4초).
`--count`를 크게 주면 그만큼 크롤이 길어진다.
"""
+ failures = 0
for post in posts:
if (post.comments or 0) < MIN_REPLIES_FOR_FETCH:
continue
- section = self.fetch_reply_section(post.url)
+ # HTTP 실패는 fetch_reply_section 안에서 조용히 None이 된다. 여기서 잡는 건
+ # 상류 SSR 페이로드 구조가 바뀌었을 때의 파싱 실패다. 그게 크롤 루프까지
+ # 올라가면 이 회차의 게시물 전량이 저장 0건이 된다.
+ try:
+ section = self.fetch_reply_section(post.url)
+ except Exception as exc: # noqa: BLE001 - 답글 실패가 게시물 저장을 막지 않는다
+ failures += 1
+ typer.echo(f" [!] Threads 답글 파싱 실패: {exc}")
+ continue
if section:
post.content_markdown = append_comment_section(
post.content_markdown or post.content, section
)
+ if failures:
+ typer.echo(f" [!] Threads 답글 파싱 실패 {failures}건 (본문만 저장)")
+
def fetch_reply_section(self, url: Optional[str]) -> Optional[str]:
"""게시물 페이지의 SSR 페이로드에서 답글을 뽑아 마크다운 섹션으로 만든다.
diff --git a/packages/skim-core/src/skim_core/crawlers/api/x.py b/packages/skim-core/src/skim_core/crawlers/api/x.py
index a5750c8..77accac 100644
--- a/packages/skim-core/src/skim_core/crawlers/api/x.py
+++ b/packages/skim-core/src/skim_core/crawlers/api/x.py
@@ -307,6 +307,23 @@ def _reply_section(
conv_id: str,
root_author_id: Optional[str],
source_tweets: Optional[list[dict]] = None,
+ ) -> Optional[str]:
+ """답글 섹션을 만들되 절대 예외를 위로 올리지 않는다.
+
+ 이 함수는 `_parse_tweets` 루프 안에서 불린다. 상류 응답 구조가 바뀌어 파싱이
+ 터지면 그 회차의 트윗이 통째로 유실되므로, 답글만 포기하고 본문은 살린다.
+ """
+ try:
+ return self._build_reply_section(conv_id, root_author_id, source_tweets)
+ except Exception as exc: # noqa: BLE001 - 답글 실패가 게시물 저장을 막지 않는다
+ typer.echo(f" [!] X 답글 파싱 실패: {exc}")
+ return None
+
+ def _build_reply_section(
+ self,
+ conv_id: str,
+ root_author_id: Optional[str],
+ source_tweets: Optional[list[dict]] = None,
) -> Optional[str]:
"""conversation에 달린 타인 답글을 본문용 섹션으로 만든다.
diff --git a/packages/skim-core/src/skim_core/crawlers/feed/ailabs.py b/packages/skim-core/src/skim_core/crawlers/feed/ailabs.py
index 3b4eb4f..d2a4d3f 100644
--- a/packages/skim-core/src/skim_core/crawlers/feed/ailabs.py
+++ b/packages/skim-core/src/skim_core/crawlers/feed/ailabs.py
@@ -16,43 +16,22 @@
import requests
from bs4 import BeautifulSoup
-from requests.adapters import HTTPAdapter
-from urllib3.util.retry import Retry
from ...enrichment import enrich_with_content
from ...feed_config import AI_LABS_SOURCES
-from ...feed_utils import fetch_feed, is_within_range
+from ...feed_utils import (
+ USER_AGENT,
+ fetch_feed,
+ is_within_range,
+ make_retrying_session,
+)
from ...models import Post
-USER_AGENT = (
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
- "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
-)
REQUEST_TIMEOUT = 20
_DATE_ANTHROPIC = re.compile(r"\b([A-Z][a-z]{2,9}) (\d{1,2}), (20\d\d)\b")
-
-def _make_session() -> requests.Session:
- """재사용 가능한 retry 붙은 HTTP 세션."""
- session = requests.Session()
- session.headers.update({"User-Agent": USER_AGENT, "Accept": "text/html,*/*"})
- retry = Retry(
- total=3,
- connect=3,
- read=3,
- backoff_factor=0.8,
- status_forcelist=(429, 500, 502, 503, 504),
- allowed_methods=frozenset(["GET", "HEAD"]),
- raise_on_status=False,
- )
- adapter = HTTPAdapter(max_retries=retry)
- session.mount("http://", adapter)
- session.mount("https://", adapter)
- return session
-
-
-_SESSION = _make_session()
+_SESSION = make_retrying_session({"Accept": "text/html,*/*"})
def _fetch_html(url: str) -> Optional[str]:
diff --git a/packages/skim-core/src/skim_core/crawlers/feed/arxiv.py b/packages/skim-core/src/skim_core/crawlers/feed/arxiv.py
index 755154f..9bcf5b1 100644
--- a/packages/skim-core/src/skim_core/crawlers/feed/arxiv.py
+++ b/packages/skim-core/src/skim_core/crawlers/feed/arxiv.py
@@ -10,7 +10,7 @@
import feedparser
from ...enrichment import enrich_papers_with_content
-from ...feed_config import ARXIV_API_URL
+from ...feed_config import ARXIV_CATEGORIES, arxiv_api_url
from ...feed_utils import KST, is_within_range
from ...models import Post
@@ -28,30 +28,43 @@ async def crawl(self, **options: Any) -> List[Post]:
hour=0, minute=0, second=0, microsecond=0
)
- feed = feedparser.parse(ARXIV_API_URL)
-
items: List[dict] = []
- for entry in feed.entries:
- pub = entry.get("published", "")
- try:
- entry_dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
- except (ValueError, AttributeError):
- continue
- if not is_within_range(entry_dt, since):
- continue
+ seen_urls: set[str] = set()
+ for category in ARXIV_CATEGORIES:
+ feed = feedparser.parse(arxiv_api_url(category))
+ for entry in feed.entries:
+ pub = entry.get("published", "")
+ try:
+ entry_dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
+ except (ValueError, AttributeError):
+ continue
+ if not is_within_range(entry_dt, since):
+ continue
- authors = ", ".join(a.get("name", "") for a in entry.get("authors", []))
- items.append(
- {
- "platform": "arxiv",
- "title": re.sub(r"\s+", " ", entry.get("title", "")).strip(),
- "url": entry.get("link", ""),
- "author": authors,
- "published": entry_dt.astimezone(timezone.utc).isoformat(),
- "summary": re.sub(r"\s+", " ", entry.get("summary", "")).strip()[:500],
- "abstract": re.sub(r"\s+", " ", entry.get("summary", "")).strip(),
- }
- )
+ url = entry.get("link", "")
+ # 논문은 여러 카테고리에 교차 등록된다. 같은 abs 링크가 두 번 들어오면
+ # enrichment도 두 번 돌고 정렬 뒤 상한만 잡아먹는다.
+ if url in seen_urls:
+ continue
+ seen_urls.add(url)
+
+ authors = ", ".join(a.get("name", "") for a in entry.get("authors", []))
+ items.append(
+ {
+ "platform": "arxiv",
+ "title": re.sub(r"\s+", " ", entry.get("title", "")).strip(),
+ "url": url,
+ "author": authors,
+ "published": entry_dt.astimezone(timezone.utc).isoformat(),
+ "summary": re.sub(
+ r"\s+", " ", entry.get("summary", "")
+ ).strip()[:500],
+ "abstract": re.sub(
+ r"\s+", " ", entry.get("summary", "")
+ ).strip(),
+ "arxiv_category": category,
+ }
+ )
items.sort(key=lambda x: x.get("published", ""), reverse=True)
items = items[:count]
@@ -65,7 +78,7 @@ def _item_to_post(self, item: dict) -> Post:
extras = {
key: value
for key, value in item.items()
- if key in ("enrichment_method", "enrichment_error")
+ if key in ("enrichment_method", "enrichment_error", "arxiv_category")
and value is not None
}
return Post(
diff --git a/packages/skim-core/src/skim_core/crawlers/feed/bluesky.py b/packages/skim-core/src/skim_core/crawlers/feed/bluesky.py
new file mode 100644
index 0000000..cbaf55d
--- /dev/null
+++ b/packages/skim-core/src/skim_core/crawlers/feed/bluesky.py
@@ -0,0 +1,191 @@
+"""
+@file bluesky.py
+@description Bluesky 크롤러 (무인증 공개 XRPC)
+
+threads/x/linkedin과 달리 로그인이 필요 없다. `public.api.bsky.app`이 무인증으로
+authorFeed와 postThread를 내주므로 계정이 노출되지 않는다. 대신 계정 팔로우가
+소스 목록을 소유하지 않으므로 볼 계정을 `BLUESKY_ACCOUNTS`에서 관리한다
+(searchPosts는 403이라 키워드 수집은 못 한다).
+"""
+
+import time
+from datetime import datetime, timedelta, timezone
+from typing import Any, List, Optional
+
+import typer
+
+from ...comments import Comment, append_comment_section, render_comment_section
+from ...feed_config import (
+ BLUESKY_ACCOUNTS,
+ BLUESKY_AUTHOR_FEED_URL,
+ BLUESKY_POST_THREAD_URL,
+)
+from ...feed_utils import make_retrying_session
+from ...models import Post
+
+MAX_COMMENTS = 15
+MIN_REPLIES_FOR_FETCH = 1
+REQUEST_INTERVAL_SECONDS = 0.5
+
+_SESSION = make_retrying_session({"Accept": "application/json"})
+
+
+def _parse_created(value: str) -> Optional[datetime]:
+ try:
+ return datetime.fromisoformat((value or "").replace("Z", "+00:00"))
+ except (ValueError, AttributeError):
+ return None
+
+
+def post_web_url(uri: str, handle: str) -> str:
+ """at:// URI를 사람이 여는 주소로 바꾼다.
+
+ at://did:plc:xxx/app.bsky.feed.post/3mse... -> https://bsky.app/profile/{handle}/post/3mse...
+ """
+ rkey = (uri or "").rsplit("/", 1)[-1]
+ if not rkey or not handle:
+ return ""
+ return f"https://bsky.app/profile/{handle}/post/{rkey}"
+
+
+class BlueskyCrawler:
+ """Bluesky 공개 API 크롤러."""
+
+ platform = "bluesky"
+
+ async def crawl(self, **options: Any) -> List[Post]:
+ since: datetime = options.get(
+ "since", datetime.now(timezone.utc) - timedelta(days=1)
+ )
+ count = options.get("count")
+ no_content: bool = options.get("no_content", False)
+
+ accounts = options.get("accounts") or BLUESKY_ACCOUNTS
+ posts: List[Post] = []
+ for index, actor in enumerate(accounts):
+ if index:
+ time.sleep(REQUEST_INTERVAL_SECONDS)
+ try:
+ posts.extend(self.fetch_author_feed(actor, since))
+ except Exception as exc: # noqa: BLE001 - 한 계정 실패가 나머지를 막지 않는다
+ typer.echo(f" [!] Bluesky {actor} 수집 실패: {exc}")
+
+ posts.sort(key=lambda p: p.timestamp, reverse=True)
+ if count is not None:
+ posts = posts[:count]
+
+ if not no_content:
+ self.attach_replies(posts)
+ return posts
+
+ def fetch_author_feed(self, actor: str, since: datetime) -> List[Post]:
+ """한 계정의 최근 게시물. 리포스트는 제외한다."""
+ resp = _SESSION.get(
+ BLUESKY_AUTHOR_FEED_URL,
+ params={"actor": actor, "limit": 50, "filter": "posts_no_replies"},
+ timeout=20,
+ )
+ resp.raise_for_status()
+ feed = resp.json().get("feed") or []
+
+ results: List[Post] = []
+ for entry in feed:
+ # reason이 있으면 남의 글을 올린 리포스트다. 원저자 타임라인에서 받는다.
+ if entry.get("reason"):
+ continue
+ post = self._entry_to_post(entry, actor)
+ if post is None:
+ continue
+ created = _parse_created(post.timestamp)
+ if created and created < since:
+ continue
+ results.append(post)
+ return results
+
+ def attach_replies(self, posts: List[Post]) -> None:
+ """답글을 정본 본문 뒤에 잇는다. 게시물당 요청 1건."""
+ failures = 0
+ for index, post in enumerate(posts):
+ if (post.comments or 0) < MIN_REPLIES_FOR_FETCH:
+ continue
+ if index:
+ time.sleep(REQUEST_INTERVAL_SECONDS)
+ uri = getattr(post, "at_uri", "")
+ if not uri:
+ continue
+ try:
+ section = self.fetch_reply_section(uri)
+ except Exception as exc: # noqa: BLE001 - 답글 실패가 게시물 저장을 막지 않는다
+ failures += 1
+ typer.echo(f" [!] Bluesky 답글 수집 실패: {exc}")
+ continue
+ if section:
+ post.content_markdown = append_comment_section(
+ post.content_markdown or post.content, section
+ )
+
+ if failures:
+ typer.echo(f" [!] Bluesky 답글 수집 실패 {failures}건 (본문만 저장)")
+
+ def fetch_reply_section(self, uri: str) -> Optional[str]:
+ """postThread에서 직계 답글만 뽑는다. 작성자 self-thread는 건너뛴다."""
+ resp = _SESSION.get(
+ BLUESKY_POST_THREAD_URL,
+ params={"uri": uri, "depth": 1},
+ timeout=20,
+ )
+ if resp.status_code != 200:
+ return None
+ thread = resp.json().get("thread") or {}
+ root_handle = ((thread.get("post") or {}).get("author") or {}).get(
+ "handle"
+ ) or ""
+
+ collected: List[Comment] = []
+ for reply in thread.get("replies") or []:
+ reply_post = reply.get("post") or {}
+ author = (reply_post.get("author") or {}).get("handle") or "unknown"
+ # 작성자 self-reply 연작은 이미 본문에 담기는 흐름이라 뺀다.
+ if author and author == root_handle:
+ continue
+ text = ((reply_post.get("record") or {}).get("text") or "").strip()
+ if not text:
+ continue
+ created = (reply_post.get("record") or {}).get("createdAt") or ""
+ collected.append(
+ Comment(
+ author=f"@{author}",
+ text=text,
+ score=reply_post.get("likeCount"),
+ created=created[:16].replace("T", " ") or None,
+ )
+ )
+
+ return render_comment_section(
+ "Bluesky Replies", collected, max_comments=MAX_COMMENTS, score_unit="like"
+ )
+
+ def _entry_to_post(self, entry: dict, source: str) -> Optional[Post]:
+ raw = entry.get("post") or {}
+ record = raw.get("record") or {}
+ text = (record.get("text") or "").strip()
+ if not text:
+ return None
+ author = raw.get("author") or {}
+ handle = author.get("handle") or ""
+ created = _parse_created(record.get("createdAt", ""))
+
+ return Post(
+ platform=self.platform,
+ author=author.get("displayName") or handle or "unknown",
+ content=text,
+ content_markdown=text,
+ timestamp=created.astimezone(timezone.utc).isoformat() if created else "",
+ url=post_web_url(raw.get("uri", ""), handle),
+ likes=raw.get("likeCount"),
+ comments=raw.get("replyCount"),
+ reposts=raw.get("repostCount"),
+ source=source,
+ external_id=raw.get("uri", "") or None,
+ at_uri=raw.get("uri", ""),
+ )
diff --git a/packages/skim-core/src/skim_core/crawlers/feed/hackernews.py b/packages/skim-core/src/skim_core/crawlers/feed/hackernews.py
index 845415a..41ae5b2 100644
--- a/packages/skim-core/src/skim_core/crawlers/feed/hackernews.py
+++ b/packages/skim-core/src/skim_core/crawlers/feed/hackernews.py
@@ -14,7 +14,7 @@
from bs4 import BeautifulSoup
from ...enrichment import enrich_with_content
-from ...feed_config import HACKERNEWS_RSS
+from ...feed_config import HACKERNEWS_FEEDS
from ...feed_utils import fetch_feed
from ...models import Post
from ...timestamp import epoch_to_iso
@@ -182,7 +182,18 @@ async def crawl(self, **options: Any) -> List[Post]:
no_content = options.get("no_content", False)
if since:
- items = fetch_feed(HACKERNEWS_RSS, "hackernews", since)
+ # newest는 30점 문턱이 걸려 있고 show/ask는 없다. Ask/Show HN은 링크가 아니라
+ # 본문이 알맹이라 점수 문턱에 걸리면 대부분 사라진다.
+ items = []
+ seen_links: set[str] = set()
+ for name, feed_url in HACKERNEWS_FEEDS.items():
+ for item in fetch_feed(feed_url, name, since):
+ link = item.get("url") or ""
+ # 점수가 오른 Show HN은 newest에도 올라와 같은 글이 두 번 온다.
+ if link and link in seen_links:
+ continue
+ seen_links.add(link)
+ items.append(item)
items.sort(key=lambda x: x.get("published", ""), reverse=True)
# CLI가 마지막에 posts[:count]로 자르므로, 버려질 항목을 enrichment하지 않는다.
if options.get("count") is not None:
@@ -194,7 +205,9 @@ async def crawl(self, **options: Any) -> List[Post]:
# 링크 원문 추출. HN item 페이지가 URL인 항목(Ask/Show HN)은
# 추출할 원문이 없으므로 건너뛰고 Algolia 스토리 텍스트로 채운다.
external = [
- item for item in items if "news.ycombinator.com" not in (item.get("url") or "")
+ item
+ for item in items
+ if "news.ycombinator.com" not in (item.get("url") or "")
]
if external:
enrich_with_content(external)
@@ -212,9 +225,9 @@ async def crawl(self, **options: Any) -> List[Post]:
# 거기서 못 뽑을 때만 item API로 폴백한다.
# Top Stories 경로가 Firebase에서 이미 채운 값은 덮지 않는다.
if item.get("likes") is None:
- metrics = metrics_from_feed(item.get("content_html", "")) or fetch_hn_metrics(
- story_id
- )
+ metrics = metrics_from_feed(
+ item.get("content_html", "")
+ ) or fetch_hn_metrics(story_id)
if metrics:
item["likes"] = metrics["likes"]
item["num_comments"] = metrics["comments"]
@@ -232,7 +245,9 @@ def _fetch_top_story_items(self, count: int) -> List[dict]:
items: List[dict] = []
for i, story_id in enumerate(story_ids):
try:
- item_resp = requests.get(f"{HN_API_BASE}/item/{story_id}.json", timeout=10)
+ item_resp = requests.get(
+ f"{HN_API_BASE}/item/{story_id}.json", timeout=10
+ )
item_resp.raise_for_status()
item = item_resp.json()
@@ -246,7 +261,8 @@ def _fetch_top_story_items(self, count: int) -> List[dict]:
"author": item.get("by", "unknown"),
"title": item.get("title", ""),
"url": (
- item.get("url") or f"https://news.ycombinator.com/item?id={story_id}"
+ item.get("url")
+ or f"https://news.ycombinator.com/item?id={story_id}"
),
"published": epoch_to_iso(time_value) if time_value else "",
"likes": item.get("score", 0),
diff --git a/packages/skim-core/src/skim_core/crawlers/feed/huggingface.py b/packages/skim-core/src/skim_core/crawlers/feed/huggingface.py
index 0bface0..33bf62d 100644
--- a/packages/skim-core/src/skim_core/crawlers/feed/huggingface.py
+++ b/packages/skim-core/src/skim_core/crawlers/feed/huggingface.py
@@ -14,6 +14,17 @@
from ...models import Post
+def _parse_iso(value: str) -> Any:
+ """HF API의 ISO8601(Z) 문자열을 aware datetime으로. 실패하면 None.
+
+ 발행일 불명을 크롤 시각으로 채우면 시간축이 왜곡되므로 미상으로 남긴다.
+ """
+ try:
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except (ValueError, AttributeError):
+ return None
+
+
class HuggingFaceCrawler:
platform = "huggingface"
@@ -29,37 +40,45 @@ async def crawl(self, **options: Any) -> List[Post]:
items: List[dict] = []
for p in papers:
- paper_id = p.get("paper", {}).get("id", "")
- authors = ", ".join(
- a.get("name", "") for a in p.get("paper", {}).get("authors", [])[:5]
- )
- if len(p.get("paper", {}).get("authors", [])) > 5:
+ paper = p.get("paper") or {}
+ paper_id = paper.get("id", "")
+ authors_all = paper.get("authors") or []
+ authors = ", ".join(a.get("name", "") for a in authors_all[:5])
+ if len(authors_all) > 5:
authors += " et al."
- pub = p.get("publishedAt", "")
- try:
- entry_dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
- published = entry_dt.astimezone(timezone.utc).isoformat()
- except (ValueError, AttributeError):
- # 발행일 불명을 크롤 시각으로 채우면 시간축이 왜곡된다. 미상으로 남긴다.
- entry_dt = None
- published = ""
-
- # CLI 계약(--days)대로 since 이전 논문은 제외. 발행일 미상은 보수적으로 포함.
- if since and entry_dt and entry_dt < since:
+ entry_dt = _parse_iso(p.get("publishedAt", ""))
+ published = (
+ entry_dt.astimezone(timezone.utc).isoformat() if entry_dt else ""
+ )
+
+ # Daily Papers는 큐레이션 목록이라 publishedAt(=arXiv 발행일)이 며칠에서
+ # 몇 주 전이다. 그걸로 --days 창을 자르면 오늘 올라온 논문이 통째로
+ # 걸러진다 (2026-08-10 실측: 최신 목록의 publishedAt 최댓값이 5일 전이라
+ # 3일 창에서 0건). HF가 목록에 올린 날짜로 거른다.
+ daily_dt = _parse_iso(paper.get("submittedOnDailyAt", ""))
+ window_dt = daily_dt or entry_dt
+ if since and window_dt and window_dt < since:
continue
items.append(
{
"platform": "huggingface",
"title": p.get("title", ""),
- "url": f"https://huggingface.co/papers/{paper_id}" if paper_id else "",
+ "url": f"https://huggingface.co/papers/{paper_id}"
+ if paper_id
+ else "",
"author": authors,
"published": published,
"summary": re.sub(r"\s+", " ", p.get("summary", "")).strip()[:500],
"abstract": re.sub(r"\s+", " ", p.get("summary", "")).strip(),
"thumbnail": p.get("thumbnail", ""),
"num_comments": p.get("numComments", 0),
+ "submitted_on_daily_at": daily_dt.astimezone(
+ timezone.utc
+ ).isoformat()
+ if daily_dt
+ else "",
}
)
@@ -74,8 +93,8 @@ def _item_to_post(self, item: dict) -> Post:
extras = {
key: value
for key, value in item.items()
- if key in ("enrichment_method", "enrichment_error")
- and value is not None
+ if key in ("enrichment_method", "enrichment_error", "submitted_on_daily_at")
+ and value
}
return Post(
platform=item.get("platform", self.platform),
diff --git a/packages/skim-core/src/skim_core/crawlers/feed/lobsters.py b/packages/skim-core/src/skim_core/crawlers/feed/lobsters.py
new file mode 100644
index 0000000..c001f36
--- /dev/null
+++ b/packages/skim-core/src/skim_core/crawlers/feed/lobsters.py
@@ -0,0 +1,161 @@
+"""
+@file lobsters.py
+@description Lobsters 크롤러 (RSS + 게시물별 JSON)
+
+Hacker News와 같은 링크 애그리게이터지만 태그가 붙고 노이즈가 적다. RSS로 목록을
+받고, 게시물 JSON이 `comment_plain`/`score`/`depth`를 그대로 줘서 Comment에 1:1로
+매핑된다 (HN처럼 Algolia를 따로 부를 필요가 없다).
+"""
+
+import time
+from datetime import datetime, timedelta, timezone
+from typing import Any, List, Optional
+
+import typer
+
+from ...comments import Comment, append_comment_section, render_comment_section
+from ...enrichment import enrich_with_content
+from ...feed_config import LOBSTERS_ITEM_JSON, LOBSTERS_RSS
+from ...feed_utils import fetch_feed, make_retrying_session
+from ...models import Post
+
+MAX_COMMENTS = 15
+# 게시물당 요청 1건이 늘어난다. 공개 API에 예의를 지키는 간격.
+COMMENT_REQUEST_INTERVAL_SECONDS = 1.0
+
+_SESSION = make_retrying_session({"Accept": "application/json"})
+
+
+def _short_id(item: dict) -> Optional[str]:
+ """RSS guid(https://lobste.rs/s/rsztog)에서 short_id를 뽑는다.
+
+ fetch_feed는 guid를 `external_id` 키로 넘긴다. `guid`/`id`로 읽으면 항상 None이라
+ 댓글이 한 건도 안 붙는다.
+ """
+ guid = (item.get("external_id") or "").rstrip("/")
+ if "/s/" not in guid:
+ return None
+ short = guid.rsplit("/", 1)[-1].strip()
+ return short or None
+
+
+def fetch_item(short_id: str) -> Optional[dict]:
+ """게시물 JSON. 댓글과 본문 폴백을 한 번의 요청으로 함께 받는다."""
+ resp = _SESSION.get(LOBSTERS_ITEM_JSON.format(short_id=short_id), timeout=20)
+ if resp.status_code != 200:
+ return None
+ return resp.json()
+
+
+def comment_section_from(payload: dict) -> Optional[str]:
+ """게시물 JSON의 댓글 트리를 본문용 마크다운 섹션으로 만든다."""
+ collected: List[Comment] = []
+ for raw in payload.get("comments") or []:
+ if raw.get("is_deleted") or raw.get("is_moderated"):
+ continue
+ text = (raw.get("comment_plain") or "").strip()
+ if not text:
+ continue
+ created = raw.get("created_at") or ""
+ collected.append(
+ Comment(
+ author=raw.get("commenting_user") or "unknown",
+ text=text,
+ score=raw.get("score"),
+ created=created[:16].replace("T", " ") or None,
+ depth=int(raw.get("depth") or 0),
+ )
+ )
+
+ return render_comment_section(
+ "Lobsters Comments", collected, max_comments=MAX_COMMENTS
+ )
+
+
+class LobstersCrawler:
+ """Lobsters RSS 크롤러."""
+
+ platform = "lobsters"
+
+ async def crawl(self, **options: Any) -> List[Post]:
+ since: datetime = options.get(
+ "since", datetime.now(timezone.utc) - timedelta(days=1)
+ )
+ count = options.get("count")
+ no_content: bool = options.get("no_content", False)
+
+ items = fetch_feed(LOBSTERS_RSS, "lobsters", since)
+ items.sort(key=lambda x: x.get("published", ""), reverse=True)
+ if count is not None:
+ items = items[:count]
+ if not items:
+ return []
+
+ if not no_content:
+ # 링크 원문을 본문으로 채운다. 자체 텍스트 글(url이 lobste.rs)은 원문이
+ # 없으므로 JSON의 description_plain이 본문 자리를 대신한다.
+ external = [
+ item for item in items if "lobste.rs" not in (item.get("url") or "")
+ ]
+ if external:
+ enrich_with_content(external)
+
+ posts = [self._item_to_post(item) for item in items]
+ if not no_content:
+ self.attach_details(posts, items)
+ return posts
+
+ def attach_details(self, posts: List[Post], items: List[dict]) -> None:
+ """게시물 JSON 1건으로 댓글 섹션과 본문 폴백을 함께 채운다.
+
+ 댓글만 받고 끝내면 원문 추출이 실패한 글이 본문 0자로 저장된다. 같은 응답에
+ `description_plain`이 들어 있으므로 추가 요청 없이 최저선을 채울 수 있다.
+ """
+ failures = 0
+ for index, (post, item) in enumerate(zip(posts, items)):
+ short_id = _short_id(item)
+ if not short_id:
+ continue
+ if index:
+ time.sleep(COMMENT_REQUEST_INTERVAL_SECONDS)
+ try:
+ payload = fetch_item(short_id)
+ section = comment_section_from(payload) if payload else None
+ except Exception as exc: # noqa: BLE001 - 댓글 실패가 게시글 저장을 막지 않는다
+ failures += 1
+ typer.echo(f" [!] Lobsters 게시물 조회 실패({short_id}): {exc}")
+ continue
+
+ body = (post.content_markdown or "").strip()
+ if not body and payload:
+ body = (payload.get("description_plain") or "").strip()
+ if body:
+ post.content_markdown = body
+ if section:
+ post.content_markdown = append_comment_section(
+ post.content_markdown or post.content, section
+ )
+
+ if failures:
+ typer.echo(f" [!] Lobsters 게시물 조회 실패 {failures}건 (본문만 저장)")
+
+ def _item_to_post(self, item: dict) -> Post:
+ extras = {
+ key: value
+ for key, value in item.items()
+ if key in ("enrichment_method", "enrichment_error") and value is not None
+ }
+ short_id = _short_id(item)
+ return Post(
+ platform=self.platform,
+ author=item.get("author", ""),
+ title=item.get("title", ""),
+ content="",
+ timestamp=item.get("published", ""),
+ url=item.get("url", ""),
+ summary=item.get("summary", ""),
+ content_markdown=item.get("content_markdown"),
+ word_count=item.get("word_count"),
+ external_id=short_id,
+ **extras,
+ )
diff --git a/packages/skim-core/src/skim_core/db.py b/packages/skim-core/src/skim_core/db.py
index 48d84c9..8c9739f 100644
--- a/packages/skim-core/src/skim_core/db.py
+++ b/packages/skim-core/src/skim_core/db.py
@@ -15,7 +15,8 @@
import sqlite3
import sys
from pathlib import Path
-from typing import List, Optional
+from typing import Any, List, Optional
+from urllib.parse import parse_qsl, urlsplit, urlunsplit
from .paths import DATA_DIR
@@ -24,6 +25,19 @@
# API형 플랫폼은 본문이 content_markdown이 아니라 content에 담긴다 (word_count 정규화용).
_API_BODY_PLATFORMS = {"linkedin", "threads", "x", "reddit"}
+# 클러스터 키를 만들 때 떼는 추적 파라미터. 같은 글을 여러 소스가 다르게 링크해도
+# 한 키로 묶으려는 목적이라, 의미를 바꿀 수 있는 파라미터는 일부러 남긴다.
+_TRACKING_PARAM_PREFIXES = ("utm_",)
+_TRACKING_PARAMS = {
+ "fbclid",
+ "gclid",
+ "igshid",
+ "mc_cid",
+ "mc_eid",
+ "ref_src",
+ "si",
+}
+
SCHEMA = """\
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -43,6 +57,7 @@
content_markdown TEXT,
word_count INTEGER,
extra TEXT,
+ canonical_url TEXT,
crawled_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(platform, external_id)
);
@@ -169,14 +184,114 @@ def get_connection(db_path: Optional[Path] = None) -> sqlite3.Connection:
return conn
+def _field(post: Any, name: str) -> Any:
+ """Post 객체와 dict 양쪽에서 필드를 읽는다."""
+ if isinstance(post, dict):
+ return post.get(name)
+ return getattr(post, name, None)
+
+
+def canonical_body(post: Any, platform: Optional[str] = None) -> str:
+ """정본 본문을 반환한다. 없으면 빈 문자열.
+
+ API형 플랫폼은 크롤러가 본문을 `content`에 담아 오고 `save_posts`가 저장 직전에
+ `content_markdown`으로 승격한다. 결손 판정이 승격 전 값을 보면 API형 4종이
+ 정상 저장돼도 전량 실패로 잡히므로, 판정과 저장이 이 함수를 함께 써야 한다.
+ """
+ body = (_field(post, "content_markdown") or "").strip()
+ if body:
+ return body
+ row_platform = _field(post, "platform") or platform or ""
+ if row_platform in _API_BODY_PLATFORMS:
+ return (_field(post, "content") or "").strip()
+ return ""
+
+
+def canonical_url_for(url: Optional[str]) -> Optional[str]:
+ """크로스소스 중복을 묶는 클러스터 키를 만든다. 행을 병합하지는 않는다.
+
+ 한 발표를 ailabs/hackernews/reddit/x가 각자 링크하면 지금은 별개 행 4개다.
+ 스킴, www/m 서브도메인, 추적 파라미터, fragment, 끝 슬래시만 정규화해서
+ "같은 원문"을 알아볼 수 있게 한다.
+ """
+ raw = (url or "").strip()
+ if not raw:
+ return None
+ try:
+ parts = urlsplit(raw)
+ except ValueError:
+ return None
+ if not parts.netloc:
+ return None
+
+ host = parts.netloc.lower()
+ if "@" not in host:
+ for prefix in ("www.", "m."):
+ if host.startswith(prefix):
+ host = host[len(prefix) :]
+ break
+
+ kept = [
+ (key, value)
+ for key, value in parse_qsl(parts.query, keep_blank_values=True)
+ if key.lower() not in _TRACKING_PARAMS
+ and not key.lower().startswith(_TRACKING_PARAM_PREFIXES)
+ ]
+ query = "&".join(f"{key}={value}" for key, value in sorted(kept))
+
+ path = parts.path
+ if len(path) > 1 and path.endswith("/"):
+ path = path.rstrip("/")
+
+ return urlunsplit(("https", host, path, query, ""))
+
+
+def backup_db(dest: Path, db_path: Optional[Path] = None) -> Path:
+ """DB를 dest로 온라인 백업한다. 크롤 중에 돌려도 안전하다.
+
+ stdlib `sqlite3.Connection.backup()`이라 새 의존성이 없고, 파일 복사와 달리
+ WAL이 진행 중이어도 일관된 스냅샷이 나온다.
+ """
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ source = get_connection(db_path)
+ try:
+ target = sqlite3.connect(str(dest))
+ try:
+ source.backup(target)
+ finally:
+ target.close()
+ finally:
+ source.close()
+ return dest
+
+
+def check_integrity(db_path: Optional[Path] = None) -> str:
+ """`PRAGMA quick_check` 결과를 반환한다. 정상이면 'ok'.
+
+ integrity_check는 649MB에서 분 단위로 걸려 데일리에 못 넣는다. quick_check는
+ 인덱스 대조를 생략해 훨씬 빠르고 페이지 손상은 그대로 잡는다.
+ """
+ conn = get_connection(db_path)
+ try:
+ row = conn.execute("PRAGMA quick_check(1)").fetchone()
+ except sqlite3.DatabaseError as exc:
+ return f"error: {exc}"
+ finally:
+ conn.close()
+ return row[0] if row else "unknown"
+
+
def init_db(db_path: Optional[Path] = None) -> None:
"""스키마를 초기화합니다. 이미 존재하면 무시."""
conn = get_connection(db_path)
- conn.executescript(SCHEMA)
- _ensure_runs_columns(conn)
- _migrate_research_runs(conn)
- _migrate_tracked_sources(conn)
- conn.close()
+ try:
+ conn.executescript(SCHEMA)
+ _ensure_runs_columns(conn)
+ _migrate_research_runs(conn)
+ _migrate_tracked_sources(conn)
+ _migrate_posts_columns(conn)
+ finally:
+ conn.close()
def migrate_canonical_body(db_path: Optional[Path] = None) -> dict:
@@ -256,6 +371,49 @@ def _migrate_tracked_sources(conn: sqlite3.Connection) -> None:
conn.commit()
+def _migrate_posts_columns(conn: sqlite3.Connection) -> None:
+ """posts 멱등 migration.
+
+ 인덱스를 SCHEMA가 아니라 여기서 만든다. 기존 DB는 `CREATE TABLE IF NOT EXISTS`가
+ 컬럼을 추가해주지 않아, SCHEMA 안에 인덱스를 두면 컬럼이 생기기 전에 실행돼 죽는다.
+ """
+ _ensure_column(conn, "posts", "canonical_url", "TEXT")
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_posts_canonical_url ON posts(canonical_url) "
+ "WHERE canonical_url IS NOT NULL AND TRIM(canonical_url) <> ''"
+ )
+ conn.commit()
+
+
+def backfill_canonical_urls(db_path: Optional[Path] = None, batch: int = 5000) -> int:
+ """canonical_url이 비어 있는 기존 행을 채운다 (멱등). 채운 건수 반환."""
+ conn = get_connection(db_path)
+ filled = 0
+ try:
+ _migrate_posts_columns(conn)
+ while True:
+ rows = conn.execute(
+ """SELECT id, url FROM posts
+ WHERE canonical_url IS NULL
+ AND url IS NOT NULL AND TRIM(url) != ''
+ LIMIT ?""",
+ (batch,),
+ ).fetchall()
+ if not rows:
+ break
+ # 정규화에 실패한 행도 ''로 확정해 다음 배치에 다시 걸리지 않게 한다.
+ # NULL로 남기면 같은 행을 매 라운드 다시 읽어 루프가 안 끝난다.
+ updates = [(canonical_url_for(row["url"]) or "", row["id"]) for row in rows]
+ conn.executemany("UPDATE posts SET canonical_url = ? WHERE id = ?", updates)
+ conn.commit()
+ filled += sum(1 for value, _ in updates if value)
+ if len(rows) < batch:
+ break
+ finally:
+ conn.close()
+ return filled
+
+
def _ensure_runs_columns(conn: sqlite3.Connection) -> None:
"""기존 DB의 runs 테이블에 누락된 컬럼을 추가합니다."""
existing = {
@@ -288,42 +446,43 @@ def _pid_is_alive(pid: Optional[int]) -> bool:
def cleanup_stale_runs(db_path: Optional[Path] = None) -> int:
"""비정상 종료로 남은 running run을 interrupted로 정리합니다."""
conn = get_connection(db_path)
- _ensure_runs_columns(conn)
host = socket.gethostname()
stale_ids: list[int] = []
+ try:
+ _ensure_runs_columns(conn)
+ rows = conn.execute("""
+ SELECT id, current_platform, runner_pid, runner_host
+ FROM runs
+ WHERE status = 'running' AND finished_at IS NULL
+ """).fetchall()
+
+ for row in rows:
+ runner_host = row["runner_host"]
+ runner_pid = row["runner_pid"]
+ if runner_host and runner_host != host:
+ continue
+ if _pid_is_alive(runner_pid):
+ continue
+ stale_ids.append(row["id"])
+ current_platform = row["current_platform"]
+ detail = (
+ f"프로세스 비정상 종료로 stale run 정리"
+ f"{f' (중단 지점: {current_platform})' if current_platform else ''}"
+ )
+ conn.execute(
+ """
+ UPDATE runs
+ SET finished_at = datetime('now'),
+ status = 'interrupted',
+ summary = ?
+ WHERE id = ?
+ """,
+ (detail, row["id"]),
+ )
- rows = conn.execute("""
- SELECT id, current_platform, runner_pid, runner_host
- FROM runs
- WHERE status = 'running' AND finished_at IS NULL
- """).fetchall()
-
- for row in rows:
- runner_host = row["runner_host"]
- runner_pid = row["runner_pid"]
- if runner_host and runner_host != host:
- continue
- if _pid_is_alive(runner_pid):
- continue
- stale_ids.append(row["id"])
- current_platform = row["current_platform"]
- detail = (
- f"프로세스 비정상 종료로 stale run 정리"
- f"{f' (중단 지점: {current_platform})' if current_platform else ''}"
- )
- conn.execute(
- """
- UPDATE runs
- SET finished_at = datetime('now'),
- status = 'interrupted',
- summary = ?
- WHERE id = ?
- """,
- (detail, row["id"]),
- )
-
- conn.commit()
- conn.close()
+ conn.commit()
+ finally:
+ conn.close()
return len(stale_ids)
@@ -338,6 +497,32 @@ def save_posts(
saved = 0
errors = 0
last_error: Optional[str] = None
+ try:
+ saved, errors, last_error = _save_posts_rows(conn, posts, platform, source)
+ conn.commit()
+ finally:
+ # 여기서 닫지 않으면 sqlite3.Error가 아닌 예외(예: extra에 직렬화 불가 값이
+ # 섞였을 때의 json.dumps TypeError)에서 RESERVED 락이 남는다. 그러면 뒤따르는
+ # finish_run이 60초를 기다리다 `database is locked`로 죽어 원래 오류를 덮는다.
+ conn.close()
+ if errors:
+ print(
+ f"[skim] save_posts: {errors}개 저장 실패 (마지막 오류: {last_error})",
+ file=sys.stderr,
+ )
+ return saved
+
+
+def _save_posts_rows(
+ conn: sqlite3.Connection,
+ posts: list,
+ platform: str,
+ source: Optional[str],
+) -> tuple[int, int, Optional[str]]:
+ """save_posts의 행 루프. (저장 건수, 실패 건수, 마지막 오류)를 반환한다."""
+ saved = 0
+ errors = 0
+ last_error: Optional[str] = None
for post in posts:
data = post.model_dump() if hasattr(post, "model_dump") else post
@@ -347,19 +532,13 @@ def save_posts(
# 혼합 배치에서 인자 platform으로 저장하면 row가 오라벨링된다.
row_platform = data.get("platform") or platform
- if (
- row_platform in _API_BODY_PLATFORMS
- and not (data.get("content_markdown") or "").strip()
- ):
- body = (data.get("content") or "").strip()
- if body:
- data["content_markdown"] = body
+ body = canonical_body(data, platform)
+ if body and not (data.get("content_markdown") or "").strip():
+ data["content_markdown"] = body
# word_count 정규화: 미계산이면 정본 본문(content_markdown)에서 센다.
- if not data.get("word_count"):
- body = (data.get("content_markdown") or "").strip()
- if body:
- data["word_count"] = len(body.split())
+ if not data.get("word_count") and body:
+ data["word_count"] = len(body.split())
# extra 필드: Post 모델의 extra="allow"로 들어온 추가 필드
known_fields = {
@@ -380,7 +559,13 @@ def save_posts(
"word_count",
}
extra_data = {k: v for k, v in data.items() if k not in known_fields}
- extra_json = json.dumps(extra_data, ensure_ascii=False) if extra_data else None
+ # `default=str`: Post는 extra="allow"라 크롤러가 datetime 같은 값을 실어 보낼 수
+ # 있다. 기본 인코더는 그걸 TypeError로 터뜨려 배치 전체를 롤백시킨다.
+ extra_json = (
+ json.dumps(extra_data, ensure_ascii=False, default=str)
+ if extra_data
+ else None
+ )
url = (data.get("url") or "").strip()
@@ -418,9 +603,10 @@ def save_posts(
"""INSERT INTO posts
(platform, source, external_id, author, title, content,
url, timestamp, likes, comments, reposts, views,
- summary, content_markdown, word_count, extra)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ summary, content_markdown, word_count, extra, canonical_url)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(platform, external_id) DO UPDATE SET
+ canonical_url = COALESCE(posts.canonical_url, excluded.canonical_url),
title = CASE
WHEN posts.title IS NULL OR TRIM(posts.title) = '' THEN excluded.title
ELSE posts.title
@@ -495,23 +681,17 @@ def save_posts(
data.get("content_markdown"),
data.get("word_count"),
extra_json,
+ canonical_url_for(url),
),
)
if conn.execute("SELECT changes()").fetchone()[0] > 0:
saved += 1
- except sqlite3.Error as e:
+ except (sqlite3.Error, TypeError, ValueError) as e:
# 개별 row 실패는 배치를 살리되, 전량 실패가 성공처럼 보이지 않게 집계한다.
errors += 1
last_error = str(e)
continue
- conn.commit()
- conn.close()
- if errors:
- print(
- f"[skim] save_posts: {errors}개 저장 실패 (마지막 오류: {last_error})",
- file=sys.stderr,
- )
- return saved
+ return saved, errors, last_error
def list_tracked_sources(
@@ -524,6 +704,7 @@ def list_tracked_sources(
where = "WHERE platform = ?"
if enabled_only:
where += " AND is_enabled = 1"
+ conn = None
try:
conn = get_connection(db_path)
rows = conn.execute(
@@ -531,9 +712,11 @@ def list_tracked_sources(
f"FROM tracked_sources {where} ORDER BY display_name COLLATE NOCASE",
(platform,),
).fetchall()
- conn.close()
except sqlite3.Error:
return []
+ finally:
+ if conn is not None:
+ conn.close()
return [dict(row) for row in rows]
@@ -594,17 +777,19 @@ def save_run(status: str = "running", db_path: Optional[Path] = None) -> int:
"""실행 기록을 생성하고 run_id를 반환합니다."""
cleanup_stale_runs(db_path)
conn = get_connection(db_path)
- _ensure_runs_columns(conn)
- cursor = conn.execute(
- """
- INSERT INTO runs (status, runner_pid, runner_host)
- VALUES (?, ?, ?)
- """,
- (status, os.getpid(), socket.gethostname()),
- )
- run_id = cursor.lastrowid
- conn.commit()
- conn.close()
+ try:
+ _ensure_runs_columns(conn)
+ cursor = conn.execute(
+ """
+ INSERT INTO runs (status, runner_pid, runner_host)
+ VALUES (?, ?, ?)
+ """,
+ (status, os.getpid(), socket.gethostname()),
+ )
+ run_id = cursor.lastrowid
+ conn.commit()
+ finally:
+ conn.close()
return run_id
@@ -616,17 +801,19 @@ def update_run_progress(
) -> None:
"""현재 처리 중인 플랫폼과 진행 상황을 기록합니다."""
conn = get_connection(db_path)
- _ensure_runs_columns(conn)
- conn.execute(
- """
- UPDATE runs
- SET current_platform = ?, summary = COALESCE(?, summary)
- WHERE id = ?
- """,
- (current_platform, summary, run_id),
- )
- conn.commit()
- conn.close()
+ try:
+ _ensure_runs_columns(conn)
+ conn.execute(
+ """
+ UPDATE runs
+ SET current_platform = ?, summary = COALESCE(?, summary)
+ WHERE id = ?
+ """,
+ (current_platform, summary, run_id),
+ )
+ conn.commit()
+ finally:
+ conn.close()
def finish_run(
@@ -638,19 +825,21 @@ def finish_run(
) -> None:
"""실행 기록을 완료 상태로 업데이트합니다."""
conn = get_connection(db_path)
- _ensure_runs_columns(conn)
- conn.execute(
- """UPDATE runs
- SET finished_at = datetime('now'),
- status = ?,
- posts_count = ?,
- summary = COALESCE(?, summary),
- current_platform = NULL
- WHERE id = ?""",
- (status, posts_count, summary, run_id),
- )
- conn.commit()
- conn.close()
+ try:
+ _ensure_runs_columns(conn)
+ conn.execute(
+ """UPDATE runs
+ SET finished_at = datetime('now'),
+ status = ?,
+ posts_count = ?,
+ summary = COALESCE(?, summary),
+ current_platform = NULL
+ WHERE id = ?""",
+ (status, posts_count, summary, run_id),
+ )
+ conn.commit()
+ finally:
+ conn.close()
def platforms_with_recent_posts(days: int, db_path: Optional[Path] = None) -> set[str]:
@@ -679,12 +868,61 @@ def platforms_with_recent_posts(days: int, db_path: Optional[Path] = None) -> se
return {row[0] for row in rows}
-def add_feedback(post_id: int, action: str) -> None:
+# 소비 상태. 새 컬럼을 만들지 않고 이미 있던 feedback 테이블을 쓴다. 이 테이블은
+# 스키마와 add_feedback()만 있고 호출자가 0개, 행이 0개인 채로 남아 있었다.
+POST_STATES = ("read", "archived")
+
+
+def set_post_state(
+ post_id: int, state: Optional[str], db_path: Optional[Path] = None
+) -> None:
+ """게시글의 소비 상태를 기록한다. state=None이면 지운다 (=안 읽음).
+
+ 상태는 게시글당 하나다. 같은 글을 read -> archived로 바꾸면 앞의 값을 지운다.
+ """
+ if state is not None and state not in POST_STATES:
+ raise ValueError(f"unknown post state: {state!r} (choose from {POST_STATES})")
+
+ conn = get_connection(db_path)
+ try:
+ conn.execute(
+ f"DELETE FROM feedback WHERE post_id = ? AND action IN "
+ f"({','.join('?' * len(POST_STATES))})",
+ (post_id, *POST_STATES),
+ )
+ if state is not None:
+ conn.execute(
+ "INSERT INTO feedback (post_id, action) VALUES (?, ?)",
+ (post_id, state),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def post_states(db_path: Optional[Path] = None) -> dict:
+ """{post_id: state} 매핑. 상태가 없는 글은 키가 없다."""
+ conn = get_connection(db_path)
+ try:
+ rows = conn.execute(
+ f"""SELECT post_id, action FROM feedback
+ WHERE action IN ({",".join("?" * len(POST_STATES))})
+ ORDER BY id""",
+ POST_STATES,
+ ).fetchall()
+ finally:
+ conn.close()
+ return {row["post_id"]: row["action"] for row in rows}
+
+
+def add_feedback(post_id: int, action: str, db_path: Optional[Path] = None) -> None:
"""사용자 피드백을 저장합니다."""
- conn = get_connection()
- conn.execute(
- "INSERT INTO feedback (post_id, action) VALUES (?, ?)",
- (post_id, action),
- )
- conn.commit()
- conn.close()
+ conn = get_connection(db_path)
+ try:
+ conn.execute(
+ "INSERT INTO feedback (post_id, action) VALUES (?, ?)",
+ (post_id, action),
+ )
+ conn.commit()
+ finally:
+ conn.close()
diff --git a/packages/skim-core/src/skim_core/enrichment.py b/packages/skim-core/src/skim_core/enrichment.py
index 7475e62..57a7773 100644
--- a/packages/skim-core/src/skim_core/enrichment.py
+++ b/packages/skim-core/src/skim_core/enrichment.py
@@ -28,8 +28,13 @@
fitz = None # type: ignore[assignment]
from .comments import Comment, append_comment_section, render_comment_section
+from .feed_utils import USER_AGENT, make_retrying_session
from .paths import workspace_root
+# 렌더 스레드를 기다릴 때 playwright 자체 타임아웃 위에 얹는 여유. 브라우저 launch와
+# 종료가 이 안에 들어간다.
+RENDER_JOIN_GRACE_SECONDS = 60
+
SRT_TO_TXT = str(workspace_root() / "scripts" / "srt_to_txt.sh")
YOUTUBE_MAX_COMMENTS = 15
@@ -108,13 +113,7 @@ def _fetch_rendered_html_sync(url: str, timeout_ms: int = 30000) -> Optional[str
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
- context = browser.new_context(
- user_agent=(
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
- "AppleWebKit/537.36 (KHTML, like Gecko) "
- "Chrome/124.0.0.0 Safari/537.36"
- )
- )
+ context = browser.new_context(user_agent=USER_AGENT)
page = context.new_page()
page.goto(url, wait_until="load", timeout=timeout_ms)
page.wait_for_timeout(1500)
@@ -138,19 +137,23 @@ def _fetch_rendered_html(url: str, timeout_ms: int = 30000) -> Optional[str]:
def _run() -> None:
result["html"] = _fetch_rendered_html_sync(url, timeout_ms)
- thread = threading.Thread(target=_run)
+ # join에 상한이 없으면 playwright launch나 page.content()가 멈출 때 크롤 프로세스가
+ # 무기한 정지한다. daemon으로 두고 상한을 걸면 그 항목만 포기하고 다음으로 넘어간다.
+ thread = threading.Thread(target=_run, daemon=True)
thread.start()
- thread.join()
+ thread.join(timeout=timeout_ms / 1000 + RENDER_JOIN_GRACE_SECONDS)
+ if thread.is_alive():
+ print(f" [!] playwright 렌더링 시간 초과 ({url[:50]}...)")
+ return None
return result["html"]
_UA_HEADERS = {
- "User-Agent": (
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
- "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
- ),
+ "User-Agent": USER_AGENT,
"Accept": "text/html,application/xhtml+xml,*/*",
}
+# 본문 추출은 소스당 여러 건을 연달아 때린다. 503 하나로 그 항목을 통째로 버리지 않는다.
+_HTTP_SESSION = make_retrying_session(_UA_HEADERS)
_PLACEHOLDER_EXACT = {
"00:00",
@@ -229,7 +232,7 @@ def _trafilatura_extract(html: str, url: str) -> Optional[dict]:
def _http_fetch_html(url: str, timeout: int = 20) -> Optional[str]:
try:
- resp = requests.get(url, headers=_UA_HEADERS, timeout=timeout)
+ resp = _HTTP_SESSION.get(url, timeout=timeout)
except requests.RequestException as e:
print(f" [!] HTTP fetch 실패 ({url[:60]}...): {e}")
return None
@@ -609,7 +612,7 @@ def _geeknews_topic_body_from_html(html: str) -> Optional[str]:
def fetch_geeknews_topic_body(topic_url: str) -> Optional[str]:
"""긱뉴스 토픽 페이지에서 한국어 요약 본문을 가져온다"""
try:
- r = requests.get(topic_url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
+ r = _HTTP_SESSION.get(topic_url, timeout=10)
return _geeknews_topic_body_from_html(r.text)
except Exception:
return None
@@ -618,7 +621,7 @@ def fetch_geeknews_topic_body(topic_url: str) -> Optional[str]:
def resolve_geeknews_original_url(topic_url: str) -> Optional[str]:
"""긱뉴스 토픽 페이지에서 원문 URL을 추출"""
try:
- r = requests.get(topic_url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
+ r = _HTTP_SESSION.get(topic_url, timeout=10)
soup = BeautifulSoup(r.text, "html.parser")
title_a = soup.select_one(".topictitle a")
if title_a:
diff --git a/packages/skim-core/src/skim_core/feed_config.py b/packages/skim-core/src/skim_core/feed_config.py
index 091c5e0..926884c 100644
--- a/packages/skim-core/src/skim_core/feed_config.py
+++ b/packages/skim-core/src/skim_core/feed_config.py
@@ -6,6 +6,14 @@
# Hacker News - hnrss.org (공식보다 풍부한 데이터, 필터링 지원)
HACKERNEWS_RSS = "https://hnrss.org/newest?points=30"
+# Show/Ask HN은 점수 문턱을 두지 않는다. 링크가 아니라 본문이 알맹이인 글이라
+# 30점 필터에 걸리면 대부분 사라진다. 크롤러는 이미 Ask/Show 본문 처리를 갖췄다.
+HACKERNEWS_FEEDS = {
+ "hackernews": HACKERNEWS_RSS,
+ "hackernews/show": "https://hnrss.org/show",
+ "hackernews/ask": "https://hnrss.org/ask",
+}
+
# GeekNews (news.hada.io) - Atom 1.0 피드
GEEKNEWS_RSS = "https://news.hada.io/rss/news"
@@ -69,6 +77,23 @@ def youtube_videos_url(canonical_id: str) -> str:
"Kakao Tech": "https://tech.kakao.com/blog/feed",
# inblog 호스팅. /feed, /rss.xml은 404고 /blog/rss만 유효하다.
"Kakao Ventures": "https://www.kakao.vc/blog/rss",
+ # --- 뉴스레터 (2026-08-10 실측 등록) ---
+ # 본문이 피드에 통째로 온다 (Latent Space 27KB, Import AI 20KB).
+ "Latent Space": "https://www.latent.space/feed",
+ "Import AI": "https://importai.substack.com/feed",
+ "Simon Willison": "https://simonwillison.net/atom/everything/",
+ # 본문 없이 제목·링크만 온다. enrichment가 원문을 받아 채운다.
+ "TLDR AI": "https://tldr.tech/api/rss/ai",
+ # --- 한국 기술 블로그 (2026-08-10 실측 등록) ---
+ # 요즘IT(yozm.wishket.com)는 뺐다. 피드에 발행일 필드가 아예 없어
+ # fetch_feed가 전량 스킵한다 (등록해도 매번 0건).
+ "Toss Tech": "https://toss.tech/rss.xml",
+ "우아한형제들": "https://techblog.woowahan.com/feed/",
+ "NAVER D2": "https://d2.naver.com/d2.atom",
+ "당근": "https://medium.com/feed/daangn",
+ # --- GitHub 릴리스 (atom content에 릴리스 노트 본문이 들어 있다) ---
+ "Claude Code Releases": "https://github.com/anthropics/claude-code/releases.atom",
+ "LangChain Releases": "https://github.com/langchain-ai/langchain/releases.atom",
}
# AI 빅테크 블로그/뉴스 (RSS + HTML 스크래핑 혼합)
@@ -95,10 +120,65 @@ def youtube_videos_url(canonical_id: str) -> str:
"type": "rss",
"url": "https://www.langchain.com/blog/rss.xml",
},
+ # 2026-08-10 실측 등록. 넷 다 200 + 엔트리 있음.
+ # Meta AI(ai.meta.com/blog/rss/)는 400, DeepSeek(api.deepseek.com/rss)은 401이라 뺐다.
+ {
+ "name": "Google DeepMind",
+ "type": "rss",
+ "url": "https://deepmind.google/blog/rss.xml",
+ },
+ {
+ "name": "Google Research",
+ "type": "rss",
+ "url": "https://research.google/blog/rss/",
+ },
+ {
+ "name": "Hugging Face Blog",
+ "type": "rss",
+ "url": "https://huggingface.co/blog/feed.xml",
+ },
+ {"name": "Mistral AI", "type": "rss", "url": "https://mistral.ai/rss.xml"},
]
-# arXiv cs.AI - Atom API (최신 50개)
-ARXIV_API_URL = "http://export.arxiv.org/api/query?search_query=cat:cs.AI&sortBy=submittedDate&sortOrder=descending&max_results=50"
+# Lobsters - RSS + 게시물별 JSON. JSON의 comments 배열이
+# comment_plain/score/depth를 그대로 줘서 Comment에 1:1로 매핑된다.
+LOBSTERS_RSS = "https://lobste.rs/rss"
+LOBSTERS_ITEM_JSON = "https://lobste.rs/s/{short_id}.json"
+
+# Bluesky - 무인증 공개 XRPC. 계정 목록이 곧 소스 목록이라 여기서 관리한다
+# (searchPosts는 403이라 키워드 수집은 못 한다).
+BLUESKY_ACCOUNTS = [
+ "bsky.app",
+]
+BLUESKY_AUTHOR_FEED_URL = "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed"
+BLUESKY_POST_THREAD_URL = "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread"
+
+# arXiv - Atom API. 카테고리별로 따로 조회해 합친다.
+# 크롤러가 합친 뒤 --count로 자르므로 카테고리를 늘려도 수집량은 그대로다.
+# 넓어지는 건 커버리지지 볼륨이 아니다. cs.AI 단독이면 cs.CL/cs.LG에만 올라온
+# 논문을 통째로 놓친다 (교차 등록이 항상 되어 있지는 않다).
+ARXIV_CATEGORIES = ["cs.AI", "cs.CL", "cs.LG", "cs.CV"]
+ARXIV_MAX_RESULTS_PER_CATEGORY = 50
+
+
+def arxiv_api_url(
+ category: str, max_results: int = ARXIV_MAX_RESULTS_PER_CATEGORY
+) -> str:
+ """카테고리별 arXiv Atom API URL.
+
+ http가 아니라 https를 쓴다. export.arxiv.org는 http를 리다이렉트하는데,
+ feedparser가 리다이렉트를 따라가며 조용히 빈 피드를 돌려주는 경우가 있다.
+ """
+ return (
+ "https://export.arxiv.org/api/query"
+ f"?search_query=cat:{category}"
+ "&sortBy=submittedDate&sortOrder=descending"
+ f"&max_results={max_results}"
+ )
+
+
+# 하위 호환: 단일 URL을 참조하던 코드가 남아 있을 수 있다.
+ARXIV_API_URL = arxiv_api_url("cs.AI")
# HuggingFace Daily Papers - JSON API
HUGGINGFACE_PAPERS_URL = "https://huggingface.co/api/daily_papers"
diff --git a/packages/skim-core/src/skim_core/feed_utils.py b/packages/skim-core/src/skim_core/feed_utils.py
index 09d1aad..9ce656c 100644
--- a/packages/skim-core/src/skim_core/feed_utils.py
+++ b/packages/skim-core/src/skim_core/feed_utils.py
@@ -9,6 +9,8 @@
import feedparser
import requests
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
# Backward-compat export: 다른 크롤러가 입력 측 윈도우 계산에 KST 사용.
# 저장 측은 UTC ISO 8601 로 강제 (fetch_feed `published` 필드).
@@ -17,12 +19,46 @@
# news.hada.io는 브라우저 토큰뿐 아니라 Chrome 메이저 버전도 본다. 2026-08-09부터
# Chrome/124가 403으로 막혀 그날 지표 수집이 절반 실패했다(128 이상은 통과).
# 차단선이 다시 올라가면 이 버전을 올린다.
-FEED_HEADERS = {
- "User-Agent": (
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
- "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
+#
+# 이 상수가 공개 소스 요청의 단일 UA다. 예전에는 enrichment, ailabs, playwright
+# 컨텍스트가 각자 Chrome/124를 들고 있어서, 여기 버전을 올려도 그쪽은 계속 막혔다.
+USER_AGENT = (
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
+)
+FEED_HEADERS = {"User-Agent": USER_AGENT}
+
+
+def make_retrying_session(extra_headers: Optional[dict] = None) -> requests.Session:
+ """429/5xx에 지수 백오프로 재시도하는 HTTP 세션.
+
+ 단발 요청이면 503 한 번에 그 소스의 그날 수집분이 빈 리스트로 끝난다. 데일리가
+ 고정 창으로 돌아 다음 날 창에는 그 항목이 다시 안 들어오므로 그대로 영구 유실이다.
+
+ 세션 쿠키로 계정이 식별되는 API 크롤러(threads/x/linkedin/reddit)에는 쓰지 않는다.
+ 거기서 자동 재시도는 차단 신호를 무시하고 계속 두드리는 것과 같다.
+ """
+ session = requests.Session()
+ session.headers.update(FEED_HEADERS)
+ if extra_headers:
+ session.headers.update(extra_headers)
+ retry = Retry(
+ total=3,
+ connect=3,
+ read=3,
+ backoff_factor=0.8,
+ status_forcelist=(429, 500, 502, 503, 504),
+ allowed_methods=frozenset(["GET", "HEAD"]),
+ raise_on_status=False,
+ respect_retry_after_header=True,
)
-}
+ adapter = HTTPAdapter(max_retries=retry)
+ session.mount("http://", adapter)
+ session.mount("https://", adapter)
+ return session
+
+
+_FEED_SESSION = make_retrying_session()
def parse_entry_date(entry) -> Optional[datetime]:
@@ -44,7 +80,7 @@ def fetch_feed(
) -> List[dict]:
"""RSS/Atom 피드를 가져와서 since 이후 항목만 반환"""
try:
- response = requests.get(url, headers=FEED_HEADERS, timeout=FEED_TIMEOUT_SECONDS)
+ response = _FEED_SESSION.get(url, timeout=FEED_TIMEOUT_SECONDS)
response.raise_for_status()
except requests.RequestException as exc:
if not quiet:
diff --git a/packages/skim-core/src/skim_core/research/serializer.py b/packages/skim-core/src/skim_core/research/serializer.py
index b93b636..c0d673a 100644
--- a/packages/skim-core/src/skim_core/research/serializer.py
+++ b/packages/skim-core/src/skim_core/research/serializer.py
@@ -73,6 +73,46 @@ def _serialize_post(row: dict, warnings: list[str]) -> dict:
return out
+BODY_FIELDS = ("content", "content_markdown", "summary")
+
+
+def shape_posts(
+ response: dict,
+ *,
+ fields: Any = None,
+ max_chars: Any = None,
+) -> dict:
+ """응답의 posts를 필요한 필드와 길이로 줄인다.
+
+ `--emit json`이 항상 content_markdown 전문을 실어서 실측 16.6MB가 나왔다
+ (`--limit 5`로도 1.4MB). 이 응답을 그대로 읽는 AI는 컨텍스트가 터진다.
+ 중간 단계가 없어서 대안은 3줄짜리 summary뿐이었다.
+
+ 자른 사실은 `truncated`와 `content_markdown_chars`로 남긴다. 조용히 자르면
+ 받는 쪽이 "본문이 원래 이만큼"이라고 믿는다.
+ """
+ posts = response.get("posts") or []
+ if max_chars is not None and max_chars >= 0:
+ for post in posts:
+ original = len(post.get("content_markdown") or "")
+ truncated = False
+ for name in BODY_FIELDS:
+ value = post.get(name)
+ if isinstance(value, str) and len(value) > max_chars:
+ post[name] = value[:max_chars]
+ truncated = True
+ if truncated:
+ post["truncated"] = True
+ post["content_markdown_chars"] = original
+
+ if fields:
+ keep = list(fields)
+ response["posts"] = [
+ {name: post[name] for name in keep if name in post} for post in posts
+ ]
+ return response
+
+
def build_response(
*,
topic: str,
diff --git a/scripts/run_daily_feed.sh b/scripts/run_daily_feed.sh
index 30d5626..c76e8f1 100755
--- a/scripts/run_daily_feed.sh
+++ b/scripts/run_daily_feed.sh
@@ -11,14 +11,45 @@ cd "$ROOT"
LOG_DIR="$ROOT/data/daily"
LOG="$LOG_DIR/cron.log"
+DOCTOR_REPORT="$LOG_DIR/doctor.txt"
mkdir -p "$LOG_DIR"
+# 중복 실행 방지. 크롤이 하루를 넘기면 다음 회차와 겹쳐 같은 DB에 동시에 쓴다.
+# macOS에는 flock이 없어서 mkdir의 원자성을 쓴다.
+LOCK_DIR="$LOG_DIR/.run.lock"
+if ! mkdir "$LOCK_DIR" 2>/dev/null; then
+ lock_owner=$(cat "$LOCK_DIR/pid" 2>/dev/null || true)
+ if [ -n "$lock_owner" ] && kill -0 "$lock_owner" 2>/dev/null; then
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] 이미 실행 중 (pid=$lock_owner). 건너뛴다." >>"$LOG"
+ exit 0
+ fi
+ # 비정상 종료가 남긴 락은 회수한다.
+ /bin/rm -rf -- "${LOCK_DIR:?}"
+ mkdir "$LOCK_DIR"
+fi
+echo "$$" >"$LOCK_DIR/pid"
+trap '/bin/rm -rf -- "${LOCK_DIR:?}"' EXIT
+
+# 로그 로테이션. 지금까지 cron.log는 무한 append였다.
+LOG_MAX_BYTES=$((10 * 1024 * 1024))
+if [ -f "$LOG" ] && [ "$(wc -c <"$LOG")" -gt "$LOG_MAX_BYTES" ]; then
+ mv "$LOG" "$LOG.1"
+fi
+
# 과거분 지표 백필의 하루 몫. GeekNews는 /topic?id= 경로에 누적 요청 한도가 있어
# (2026-08-09 관측: 하루 1,000건쯤에서 403) 한 번에 다 못 받는다. 매일 조금씩 받으면
# 한도에 걸리지 않고, 다 채워지면 대상이 없어 즉시 끝난다.
METRICS_BACKFILL_LIMIT=400
echo "======= start $(date '+%Y-%m-%d %H:%M:%S') =======" >>"$LOG"
+
+# 크롤 전에 백업한다. 스키마 변경이 전부 in-place라 되돌릴 수단이 이것뿐이다.
+# 실패해도 크롤은 계속한다 (백업이 수집을 막을 이유가 없다).
+uv run skim backup --keep 3 >>"$LOG" 2>&1 || echo "[!] 백업 실패" >>"$LOG"
+
+# --days 1을 유지한다. 발행일이 밀리는 소스(arxiv, huggingface)는 CLI의
+# min_lookback_days()가 창을 알아서 넓히므로, 여기서 전역으로 넓히면 이미
+# 저장된 항목까지 매일 다시 enrichment하게 된다.
# set -e 아래에서는 실패 즉시 죽어 종료 코드를 기록하지 못하므로 직접 받는다.
status=0
uv run skim crawl all --days 1 >>"$LOG" 2>&1 || status=$?
@@ -29,5 +60,11 @@ uv run python scripts/backfill_feed_metrics.py --limit "$METRICS_BACKFILL_LIMIT"
>>"$LOG" 2>&1 || backfill_status=$?
echo "지표 백필 exit=$backfill_status" >>"$LOG"
+# 점검 결과는 cron.log에 묻히지 않게 따로 떨군다. 이 파일만 보면 어제 상태를 안다.
+doctor_status=0
+uv run skim doctor --strict >"$DOCTOR_REPORT" 2>&1 || doctor_status=$?
+cat "$DOCTOR_REPORT" >>"$LOG"
+echo "doctor exit=$doctor_status (상세: $DOCTOR_REPORT)" >>"$LOG"
+
echo "======= end $(date '+%Y-%m-%d %H:%M:%S') exit=$status =======" >>"$LOG"
exit "$status"
diff --git a/tests/test_comment_failure_isolation.py b/tests/test_comment_failure_isolation.py
new file mode 100644
index 0000000..d51791c
--- /dev/null
+++ b/tests/test_comment_failure_isolation.py
@@ -0,0 +1,150 @@
+"""댓글 수집 실패가 게시글 저장을 막지 않는지 검증한다.
+
+AGENTS.md의 계약이지만 실제로는 지켜지지 않았다. try가 HTTP 호출만 감싸고 있어서
+상류 응답 구조가 바뀌면 파싱 예외가 크롤 루프까지 올라갔고, 그 회차의 게시글
+50건이 통째로 저장 0건이 됐다.
+"""
+
+import unittest
+from unittest.mock import MagicMock, patch
+
+from skim_core.crawlers.api.linkedin import LinkedInAPICrawler
+from skim_core.crawlers.api.reddit import RedditAPICrawler
+from skim_core.crawlers.api.threads import ThreadsAPICrawler
+from skim_core.crawlers.api.x import XAPICrawler
+from skim_core.models import Post
+
+
+def _post(**overrides):
+ data = {
+ "platform": "reddit",
+ "author": "tester",
+ "content": "original body",
+ "timestamp": "2026-08-10T00:00:00+09:00",
+ "url": "https://example.com/a",
+ "external_id": "7492224454731714560",
+ "comments": 5,
+ }
+ data.update(overrides)
+ return Post(**data)
+
+
+class RedditIsolationTests(unittest.TestCase):
+ def _crawler(self):
+ with patch.object(RedditAPICrawler, "_load_session_cookies", return_value=None):
+ return RedditAPICrawler()
+
+ def test_parse_failure_keeps_the_body(self):
+ crawler = self._crawler()
+ posts = [_post(external_id="a"), _post(external_id="b")]
+ with (
+ patch.object(
+ crawler, "fetch_comment_section", side_effect=ValueError("schema drift")
+ ),
+ patch("skim_core.crawlers.api.reddit.time.sleep"),
+ patch("skim_core.crawlers.api.reddit.typer.echo"),
+ ):
+ crawler.attach_comments(posts)
+
+ for post in posts:
+ self.assertEqual(post.content, "original body")
+
+ def test_zero_comment_posts_do_not_trip_the_circuit_breaker(self):
+ # "댓글 0건"과 "HTTP 실패"가 둘 다 None이라, 조용한 서브레딧에서 0건 글이
+ # 연속되면 남은 게시글 전체의 댓글 수집이 중단됐다.
+ crawler = self._crawler()
+ quiet = [_post(external_id=f"q{i}", comments=0) for i in range(5)]
+ loud = _post(external_id="loud", comments=9)
+ posts = quiet + [loud]
+
+ calls = []
+
+ def fake_fetch(url):
+ calls.append(url)
+ return "## Reddit Comments\n\n- **u/a**: hi"
+
+ with (
+ patch.object(crawler, "fetch_comment_section", side_effect=fake_fetch),
+ patch("skim_core.crawlers.api.reddit.time.sleep"),
+ patch("skim_core.crawlers.api.reddit.typer.echo"),
+ ):
+ crawler.attach_comments(posts)
+
+ self.assertEqual(len(calls), 1, "0건 글은 조회하지 않는다")
+ self.assertIn("Reddit Comments", loud.content_markdown or "")
+
+
+class LinkedInIsolationTests(unittest.TestCase):
+ def _crawler(self):
+ with patch.object(
+ LinkedInAPICrawler, "_load_session_cookies", return_value=None
+ ):
+ return LinkedInAPICrawler()
+
+ def test_parse_failure_keeps_the_body(self):
+ crawler = self._crawler()
+ posts = [_post(platform="linkedin", external_id="1")]
+ with (
+ patch.object(
+ crawler, "fetch_comment_section", side_effect=KeyError("included")
+ ),
+ patch("skim_core.crawlers.api.linkedin.time.sleep"),
+ patch("skim_core.crawlers.api.linkedin.typer.echo"),
+ ):
+ crawler.attach_comments(posts)
+
+ self.assertEqual(posts[0].content, "original body")
+
+ def test_zero_comment_posts_are_skipped(self):
+ crawler = self._crawler()
+ posts = [_post(platform="linkedin", external_id="1", comments=0)]
+ with (
+ patch.object(crawler, "fetch_comment_section") as fetch,
+ patch("skim_core.crawlers.api.linkedin.time.sleep"),
+ patch("skim_core.crawlers.api.linkedin.typer.echo"),
+ ):
+ crawler.attach_comments(posts)
+
+ fetch.assert_not_called()
+
+
+class ThreadsIsolationTests(unittest.TestCase):
+ def _crawler(self):
+ with patch.object(ThreadsAPICrawler, "_setup_session", return_value=None):
+ return ThreadsAPICrawler.__new__(ThreadsAPICrawler)
+
+ def test_payload_parse_failure_keeps_the_body(self):
+ crawler = self._crawler()
+ posts = [_post(platform="threads", url="https://www.threads.net/@a/post/1")]
+ response = MagicMock(status_code=200, text="")
+ response.raise_for_status.return_value = None
+ with (
+ patch("skim_core.crawlers.api.threads.requests.get", return_value=response),
+ patch.object(
+ crawler,
+ "_extract_reply_threads",
+ side_effect=TypeError("shape changed"),
+ ),
+ patch("skim_core.crawlers.api.threads.typer.echo"),
+ ):
+ crawler.attach_replies(posts)
+
+ self.assertEqual(posts[0].content, "original body")
+
+
+class XIsolationTests(unittest.TestCase):
+ def test_reply_parse_failure_returns_none(self):
+ # _reply_section은 _parse_tweets 루프 안에서 불린다. 여기서 예외가 새면
+ # 그 회차의 트윗이 통째로 유실된다.
+ crawler = XAPICrawler.__new__(XAPICrawler)
+ with (
+ patch.object(
+ crawler, "_build_reply_section", side_effect=AttributeError("legacy")
+ ),
+ patch("skim_core.crawlers.api.x.typer.echo"),
+ ):
+ self.assertIsNone(crawler._reply_section("123", "456"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_comment_sections.py b/tests/test_comment_sections.py
index 3dd7018..4fcc9aa 100644
--- a/tests/test_comment_sections.py
+++ b/tests/test_comment_sections.py
@@ -182,6 +182,8 @@ def test_attach_stops_after_consecutive_failures(self):
url=f"https://www.reddit.com/r/a/comments/{i}/x/",
content="b",
content_markdown=None,
+ # 댓글이 있다고 보고된 글이어야 조회 대상이다. 0건 글은 애초에 건너뛴다.
+ comments=5,
)
for i in range(10)
]
diff --git a/tests/test_consumption_paths.py b/tests/test_consumption_paths.py
new file mode 100644
index 0000000..9e9fd13
--- /dev/null
+++ b/tests/test_consumption_paths.py
@@ -0,0 +1,222 @@
+"""소비 경로 회귀 테스트: 출력 절단, 본문 포함 bundle, 마크다운 export, OPML 왕복."""
+
+import tempfile
+import unittest
+from pathlib import Path
+
+import typer
+
+import skim_cli.cli as main
+from skim_core.db import init_db, save_posts
+from skim_core.models import Post
+from skim_core.research.serializer import shape_posts
+
+
+def _response(bodies):
+ return {
+ "topic": "t",
+ "posts": [
+ {
+ "platform": "blogs",
+ "title": f"post {i}",
+ "url": f"https://example.com/{i}",
+ "timestamp": "2026-08-10T00:00:00+00:00",
+ "content": body,
+ "content_markdown": body,
+ "summary": body[:20],
+ "author": "a",
+ }
+ for i, body in enumerate(bodies)
+ ],
+ "stats": {"total": len(bodies)},
+ "warnings": [],
+ }
+
+
+class ShapePostsTests(unittest.TestCase):
+ def test_max_chars_truncates_every_body_field(self):
+ response = shape_posts(_response(["x" * 5000]), max_chars=100)
+ post = response["posts"][0]
+
+ self.assertEqual(len(post["content_markdown"]), 100)
+ self.assertEqual(len(post["content"]), 100)
+
+ def test_truncation_is_recorded_not_silent(self):
+ # 조용히 자르면 받는 쪽이 "본문이 원래 이만큼"이라고 믿는다.
+ post = shape_posts(_response(["x" * 5000]), max_chars=100)["posts"][0]
+
+ self.assertTrue(post["truncated"])
+ self.assertEqual(post["content_markdown_chars"], 5000)
+
+ def test_short_body_is_left_alone(self):
+ post = shape_posts(_response(["short"]), max_chars=100)["posts"][0]
+
+ self.assertEqual(post["content_markdown"], "short")
+ self.assertNotIn("truncated", post)
+
+ def test_fields_keeps_only_the_requested_keys(self):
+ response = shape_posts(_response(["body"]), fields=["platform", "title", "url"])
+
+ self.assertEqual(sorted(response["posts"][0]), ["platform", "title", "url"])
+
+ def test_no_options_leaves_the_response_untouched(self):
+ response = shape_posts(_response(["x" * 5000]))
+
+ self.assertEqual(len(response["posts"][0]["content_markdown"]), 5000)
+
+ def test_unknown_field_is_rejected_with_exit_2(self):
+ with self.assertRaises(typer.Exit) as ctx:
+ main._parse_fields("platform,not_a_field")
+ self.assertEqual(ctx.exception.exit_code, 2)
+
+ def test_known_fields_parse(self):
+ self.assertEqual(
+ main._parse_fields("platform, title ,url"), ["platform", "title", "url"]
+ )
+
+
+class RecentPostsTests(unittest.TestCase):
+ """topic 없는 bundle이 빈 posts를 주던 자리. 소비자가 sqlite3로 우회하고 있었다."""
+
+ def setUp(self):
+ self.temp_dir = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp_dir.cleanup)
+ self.db_path = Path(self.temp_dir.name) / "skim.db"
+ init_db(self.db_path)
+ save_posts(
+ [
+ Post(
+ platform="blogs",
+ author="a",
+ content="",
+ content_markdown="full body here",
+ title="T",
+ timestamp="2026-08-10T00:00:00+00:00",
+ url="https://example.com/1",
+ external_id="1",
+ )
+ ],
+ "blogs",
+ db_path=self.db_path,
+ )
+
+ def test_recent_posts_carry_the_body(self):
+ rows = main._recent_posts(self.db_path, 7, None, 10)
+
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(rows[0]["content_markdown"], "full body here")
+
+ def test_platform_filter_applies(self):
+ self.assertEqual(main._recent_posts(self.db_path, 7, ["reddit"], 10), [])
+
+
+class MarkdownExportTests(unittest.TestCase):
+ def test_frontmatter_escapes_quotes_in_titles(self):
+ text = main._post_to_markdown(
+ {
+ "title": 'He said "hi"',
+ "platform": "blogs",
+ "url": "https://example.com",
+ "timestamp": "2026-08-10T00:00:00+00:00",
+ "content_markdown": "body",
+ "word_count": 1,
+ }
+ )
+ # 따옴표를 그대로 쓰면 YAML frontmatter가 깨져 Obsidian이 못 읽는다.
+ self.assertIn('title: "He said \\"hi\\""', text)
+ self.assertTrue(text.startswith("---\n"))
+ self.assertIn("\nbody\n", text)
+
+ def test_filename_is_unique_per_index(self):
+ post = {"title": "same", "platform": "blogs", "timestamp": "2026-08-10T00:00"}
+ self.assertNotEqual(main._md_filename(post, 0), main._md_filename(post, 1))
+
+ def test_undated_post_still_gets_a_name(self):
+ self.assertTrue(
+ main._md_filename({"title": "t", "platform": "blogs"}, 0).startswith(
+ "undated-"
+ )
+ )
+
+
+OPML_SAMPLE = """
+
+ My feeds
+
+
+
+
+
+
+
+
+"""
+
+
+class OpmlTests(unittest.TestCase):
+ def test_reads_nested_outlines_and_skips_folders(self):
+ entries = main.parse_opml(OPML_SAMPLE)
+
+ self.assertEqual(len(entries), 2)
+ self.assertEqual(entries[0]["name"], "A blog")
+ self.assertEqual(entries[0]["feed_url"], "https://a.example/feed")
+ self.assertEqual(entries[0]["site_url"], "https://a.example/")
+ # htmlUrl이 없으면 피드 주소로 대신한다.
+ self.assertEqual(entries[1]["site_url"], "https://b.example/rss")
+
+ def test_doctype_is_rejected_before_parsing(self):
+ # 사용자 파일이라 신뢰 경계다. expat은 내부 엔티티를 그대로 펼쳐
+ # billion-laughs로 메모리를 태울 수 있다.
+ evil = (
+ '\n'
+ ']>\n'
+ ''
+ )
+ with self.assertRaises(ValueError):
+ main.parse_opml(evil)
+
+ def test_round_trip_preserves_every_feed(self):
+ rows = [
+ {
+ "platform": "blogs",
+ "display_name": 'Quote " name',
+ "canonical_id": "https://a.example/",
+ "feed_url": "https://a.example/feed",
+ },
+ {
+ "platform": "youtube",
+ "display_name": "Chan",
+ "canonical_id": "@chan",
+ "feed_url": "https://yt.example/feed",
+ },
+ ]
+
+ opml = main._sources_opml(rows)
+ back = main.parse_opml(opml)
+
+ self.assertEqual(
+ sorted(e["feed_url"] for e in back),
+ ["https://a.example/feed", "https://yt.example/feed"],
+ )
+ # 따옴표가 든 이름이 XML 속성을 깨지 않고 그대로 돌아와야 한다.
+ self.assertIn('Quote " name', [e["name"] for e in back])
+
+
+class BundleSummaryTests(unittest.TestCase):
+ def test_group_by_platform_lists_each_bucket(self):
+ response = _response(["a", "b"])
+ response["posts"][1]["platform"] = "reddit"
+
+ summary = main._bundle_summary(response, Path("/tmp/inv.tsv"), "platform")
+
+ self.assertIn("### blogs (1)", summary)
+ self.assertIn("### reddit (1)", summary)
+ self.assertIn("[post 0](https://example.com/0)", summary)
+
+ def test_group_by_is_optional(self):
+ summary = main._bundle_summary(_response(["a"]), Path("/tmp/inv.tsv"))
+ self.assertNotIn("Grouped by", summary)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_crawl_zero_regression.py b/tests/test_crawl_zero_regression.py
index f5032e5..1d52e91 100644
--- a/tests/test_crawl_zero_regression.py
+++ b/tests/test_crawl_zero_regression.py
@@ -15,7 +15,9 @@
def _stamp(days_ago: float) -> str:
"""`datetime('now', ...)`와 같은 UTC 축의 타임스탬프."""
- return (datetime.now(timezone.utc) - timedelta(days=days_ago)).strftime("%Y-%m-%d %H:%M:%S")
+ return (datetime.now(timezone.utc) - timedelta(days=days_ago)).strftime(
+ "%Y-%m-%d %H:%M:%S"
+ )
class PlatformsWithRecentPostsTests(unittest.TestCase):
@@ -55,7 +57,9 @@ def test_broken_database_disables_detection_instead_of_raising(self):
class ZeroResultRegressionTests(unittest.TestCase):
"""빈 리스트는 예외가 아니라서 크롤러가 깨져도 정상 종료처럼 보인다."""
- def _run_crawl(self, recent_platforms, platforms=("threads",), crawler_results=None):
+ def _run_crawl(
+ self, recent_platforms, platforms=("threads",), crawler_results=None
+ ):
with (
patch("skim_cli.cli.run_single_crawler", new_callable=AsyncMock) as crawler,
patch(
@@ -87,7 +91,9 @@ def _run_crawl(self, recent_platforms, platforms=("threads",), crawler_results=N
exited = False
except typer.Exit:
exited = True
- messages = " ".join(str(call.args[0]) for call in echo.call_args_list if call.args)
+ messages = " ".join(
+ str(call.args[0]) for call in echo.call_args_list if call.args
+ )
return finish_run, messages, exited
def test_warns_and_marks_run_degraded_when_active_platform_returns_nothing(self):
@@ -179,9 +185,18 @@ def _forwarded_since_days(self, platform, days=None):
return (midnight - options["since"]).days
def test_huggingface_looks_further_back_than_one_day(self):
- # daily papers가 싣는 publishedAt은 arXiv 발행일이라 1일 창에서는 전량 걸러진다.
+ # HF는 주말에 daily papers를 큐레이션하지 않는다. 월요일 배치가 금요일 목록을
+ # 놓치지 않으려면 3일이 필요하다.
self.assertEqual(self._forwarded_since_days("huggingface"), 3)
+ def test_arxiv_floor_survives_an_explicit_narrow_window(self):
+ # arXiv는 주말에 announce하지 않는다. 요일 규칙이 days=None일 때만 걸려 있어
+ # 일일 배치의 `--days 1`이 그걸 덮어썼고, 그래서 배치에서만 0건이 났다.
+ now = main.datetime.now(main.KST)
+ expected = 4 if now.weekday() in (0, 5, 6) else 2
+ self.assertEqual(self._forwarded_since_days("arxiv", days=1), expected)
+ self.assertEqual(self._forwarded_since_days("arxiv"), expected)
+
def test_explicit_days_cannot_shrink_below_the_platform_floor(self):
# 일일 배치가 `crawl all --days 1`로 돌기 때문에 이 경로가 실제 운영 경로다.
self.assertEqual(self._forwarded_since_days("huggingface", days=1), 3)
diff --git a/tests/test_crawler_timestamp_iso8601.py b/tests/test_crawler_timestamp_iso8601.py
index f90b199..8ab5001 100644
--- a/tests/test_crawler_timestamp_iso8601.py
+++ b/tests/test_crawler_timestamp_iso8601.py
@@ -7,6 +7,7 @@
import requests
+from skim_core import feed_utils
from skim_core.crawlers.feed.geeknews import GeekNewsCrawler
from skim_core.crawlers.feed.hackernews import HackerNewsCrawler
from skim_core.feed_utils import fetch_feed
@@ -146,7 +147,7 @@ def test_fetch_feed_emits_utc_offset(self):
response.raise_for_status = MagicMock()
# feed entries는 dict 호환되도록
with (
- patch("skim_core.feed_utils.requests.get", return_value=response),
+ patch.object(feed_utils._FEED_SESSION, "get", return_value=response),
patch("skim_core.feed_utils.feedparser.parse", return_value=feed),
):
since = datetime(2026, 4, 1, tzinfo=timezone.utc)
@@ -173,7 +174,7 @@ def test_fetch_feed_omits_old_entries(self):
response.content = b""
response.raise_for_status = MagicMock()
with (
- patch("skim_core.feed_utils.requests.get", return_value=response),
+ patch.object(feed_utils._FEED_SESSION, "get", return_value=response),
patch("skim_core.feed_utils.feedparser.parse", return_value=feed),
):
since = datetime(2026, 4, 1, tzinfo=timezone.utc)
@@ -181,7 +182,9 @@ def test_fetch_feed_omits_old_entries(self):
self.assertEqual(results, [])
def test_fetch_feed_request_failure_returns_empty(self):
- with patch("skim_core.feed_utils.requests.get", side_effect=requests.Timeout("timeout")):
+ with patch.object(
+ feed_utils._FEED_SESSION, "get", side_effect=requests.Timeout("timeout")
+ ):
since = datetime(2026, 4, 1, tzinfo=timezone.utc)
results = fetch_feed("https://feed", "src", since)
diff --git a/tests/test_db_connection_safety.py b/tests/test_db_connection_safety.py
new file mode 100644
index 0000000..8964736
--- /dev/null
+++ b/tests/test_db_connection_safety.py
@@ -0,0 +1,161 @@
+"""save_posts의 연결 수명과 정본 본문 판정 회귀 테스트.
+
+`commit()`/`close()`가 try 밖에 있어서 sqlite3.Error가 아닌 예외(예: extra에 섞인
+datetime의 json.dumps TypeError)가 나면 RESERVED 락이 남았다. 그러면 뒤따르는
+finish_run이 60초를 기다리다 `database is locked`로 죽어 원래 오류를 덮는다.
+"""
+
+import sqlite3
+import tempfile
+import unittest
+from datetime import datetime, timezone
+from pathlib import Path
+
+from skim_core.db import (
+ backup_db,
+ canonical_body,
+ canonical_url_for,
+ check_integrity,
+ init_db,
+ save_posts,
+ save_run,
+)
+from skim_core.models import Post
+
+
+def _post(**overrides):
+ data = {
+ "platform": "reddit",
+ "author": "tester",
+ "content": "body text",
+ "timestamp": "2026-08-10T00:00:00+09:00",
+ "url": "https://example.com/a",
+ "external_id": "abc",
+ }
+ data.update(overrides)
+ return Post(**data)
+
+
+class SavePostsConnectionTests(unittest.TestCase):
+ def setUp(self):
+ self.temp_dir = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp_dir.cleanup)
+ self.db_path = Path(self.temp_dir.name) / "skim.db"
+ init_db(self.db_path)
+
+ def test_non_serializable_extra_does_not_lose_the_batch(self):
+ # Post는 extra="allow"라 크롤러가 datetime을 실어 보낼 수 있다.
+ posts = [
+ _post(external_id="one", crawled_from=datetime.now(timezone.utc)),
+ _post(external_id="two"),
+ ]
+
+ saved = save_posts(posts, "reddit", db_path=self.db_path)
+
+ self.assertEqual(saved, 2, "직렬화 불가 값이 배치를 통째로 날리면 안 된다")
+
+ def test_connection_is_released_for_the_next_writer(self):
+ # 락이 남으면 이 호출이 60초 기다리다 `database is locked`로 죽는다.
+ save_posts(
+ [_post(nested={"dt": datetime.now(timezone.utc)})],
+ "reddit",
+ db_path=self.db_path,
+ )
+
+ run_id = save_run("running", self.db_path)
+
+ self.assertIsInstance(run_id, int)
+
+ def test_rows_survive_a_bad_neighbour(self):
+ conn = sqlite3.connect(self.db_path)
+ conn.execute("PRAGMA busy_timeout=1000")
+ rows = conn.execute("SELECT COUNT(*) FROM posts").fetchone()[0]
+ conn.close()
+ self.assertEqual(rows, 0)
+
+ save_posts([_post(external_id="keep")], "reddit", db_path=self.db_path)
+
+ conn = sqlite3.connect(self.db_path)
+ stored = conn.execute("SELECT COUNT(*) FROM posts").fetchone()[0]
+ conn.close()
+ self.assertEqual(stored, 1)
+
+
+class CanonicalBodyTests(unittest.TestCase):
+ """크롤 요약의 결손 집계가 save_posts와 같은 판정을 쓰는지 본다."""
+
+ def test_api_platform_body_arrives_in_content(self):
+ # 승격 전 content_markdown만 보면 API형 4종이 전량 실패로 오탐된다.
+ for platform in ("linkedin", "threads", "x", "reddit"):
+ with self.subTest(platform=platform):
+ post = _post(
+ platform=platform, content="post body", content_markdown=None
+ )
+ self.assertEqual(canonical_body(post, platform), "post body")
+
+ def test_feed_platform_content_is_not_a_body(self):
+ post = _post(
+ platform="hackernews", content="title duplicate", content_markdown=None
+ )
+ self.assertEqual(canonical_body(post, "hackernews"), "")
+
+ def test_existing_markdown_wins(self):
+ post = _post(platform="reddit", content="raw", content_markdown=" rendered ")
+ self.assertEqual(canonical_body(post, "reddit"), "rendered")
+
+
+class CanonicalUrlTests(unittest.TestCase):
+ def test_strips_tracking_and_normalises_host(self):
+ self.assertEqual(
+ canonical_url_for("http://www.Example.com/post/?utm_source=rss&id=7#top"),
+ "https://example.com/post?id=7",
+ )
+
+ def test_mobile_subdomain_matches_desktop(self):
+ self.assertEqual(
+ canonical_url_for("https://m.example.com/a"),
+ canonical_url_for("https://example.com/a"),
+ )
+
+ def test_returns_none_for_unusable_input(self):
+ for value in ("", None, "not a url", "mailto:a@b.c"):
+ with self.subTest(value=value):
+ self.assertIsNone(canonical_url_for(value))
+
+ def test_saved_post_gets_a_cluster_key(self):
+ temp_dir = tempfile.TemporaryDirectory()
+ self.addCleanup(temp_dir.cleanup)
+ db_path = Path(temp_dir.name) / "skim.db"
+ init_db(db_path)
+
+ save_posts(
+ [_post(url="https://www.example.com/story/?utm_medium=email")],
+ "reddit",
+ db_path=db_path,
+ )
+
+ conn = sqlite3.connect(db_path)
+ stored = conn.execute("SELECT canonical_url FROM posts").fetchone()[0]
+ conn.close()
+ self.assertEqual(stored, "https://example.com/story")
+
+
+class BackupTests(unittest.TestCase):
+ def test_backup_is_a_readable_copy(self):
+ temp_dir = tempfile.TemporaryDirectory()
+ self.addCleanup(temp_dir.cleanup)
+ db_path = Path(temp_dir.name) / "skim.db"
+ init_db(db_path)
+ save_posts([_post()], "reddit", db_path=db_path)
+
+ dest = backup_db(Path(temp_dir.name) / "backups" / "copy.db", db_path)
+
+ self.assertTrue(dest.exists())
+ conn = sqlite3.connect(dest)
+ self.assertEqual(conn.execute("SELECT COUNT(*) FROM posts").fetchone()[0], 1)
+ conn.close()
+ self.assertEqual(check_integrity(dest), "ok")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_feed_metadata_completeness.py b/tests/test_feed_metadata_completeness.py
index ada2fc0..658bf53 100644
--- a/tests/test_feed_metadata_completeness.py
+++ b/tests/test_feed_metadata_completeness.py
@@ -35,12 +35,16 @@ def _run(self, payload):
return items, run
def test_requests_approximate_date_so_flat_playlist_carries_timestamps(self):
- _, run = self._run([{"id": "abc123XYZ09", "title": "T", "timestamp": 1754611200}])
+ _, run = self._run(
+ [{"id": "abc123XYZ09", "title": "T", "timestamp": 1754611200}]
+ )
argv = run.call_args.args[0]
self.assertIn("youtubetab:approximate_date", argv)
def test_published_is_filled_from_timestamp(self):
- items, _ = self._run([{"id": "abc123XYZ09", "title": "T", "timestamp": 1754611200}])
+ items, _ = self._run(
+ [{"id": "abc123XYZ09", "title": "T", "timestamp": 1754611200}]
+ )
self.assertEqual(len(items), 1)
self.assertTrue(items[0]["published"])
self.assertEqual(
@@ -66,7 +70,7 @@ class FeedAuthorFallbackTests(unittest.TestCase):
def _fetch(self, entry, source_name):
parsed = type("Parsed", (), {"bozo": False, "entries": [entry]})()
with (
- patch.object(feed_utils.requests, "get", return_value=FakeResponse()),
+ patch.object(feed_utils._FEED_SESSION, "get", return_value=FakeResponse()),
patch.object(feed_utils.feedparser, "parse", return_value=parsed),
):
return feed_utils.fetch_feed(
diff --git a/tests/test_http_resilience.py b/tests/test_http_resilience.py
new file mode 100644
index 0000000..b812a54
--- /dev/null
+++ b/tests/test_http_resilience.py
@@ -0,0 +1,84 @@
+"""HTTP 재시도 공용화와 렌더 스레드 상한 회귀 테스트.
+
+`fetch_feed`가 단발 요청이라 503 한 번에 그 소스의 그날 수집분이 빈 리스트로 끝났다.
+데일리가 고정 창으로 돌아 다음 날 창에는 그 항목이 다시 안 들어오므로 영구 유실이었다.
+"""
+
+import asyncio
+import time
+import unittest
+from unittest.mock import patch
+
+from skim_core import enrichment, feed_utils
+from skim_core.crawlers.feed import ailabs
+from skim_core.feed_utils import USER_AGENT, make_retrying_session
+
+
+class RetryingSessionTests(unittest.TestCase):
+ def test_retries_on_throttling_and_server_errors(self):
+ session = make_retrying_session()
+ retry = session.get_adapter("https://example.com").max_retries
+
+ self.assertEqual(retry.total, 3)
+ for status in (429, 500, 502, 503, 504):
+ self.assertIn(status, retry.status_forcelist)
+ # 429의 Retry-After를 무시하면 재시도가 차단을 키운다.
+ self.assertTrue(retry.respect_retry_after_header)
+ self.assertGreater(retry.backoff_factor, 0)
+
+ def test_only_idempotent_methods_are_retried(self):
+ retry = make_retrying_session().get_adapter("https://example.com").max_retries
+ self.assertEqual(set(retry.allowed_methods), {"GET", "HEAD"})
+
+ def test_extra_headers_do_not_drop_the_shared_user_agent(self):
+ session = make_retrying_session({"Accept": "text/html,*/*"})
+ self.assertEqual(session.headers["User-Agent"], USER_AGENT)
+ self.assertEqual(session.headers["Accept"], "text/html,*/*")
+
+ def test_feed_fetch_uses_the_retrying_session(self):
+ self.assertIsNotNone(
+ feed_utils._FEED_SESSION.get_adapter("https://x").max_retries
+ )
+
+
+class UserAgentConvergenceTests(unittest.TestCase):
+ """UA가 흩어져 있으면 차단선이 올라갔을 때 한 곳만 고치고 넘어가게 된다."""
+
+ def test_every_public_fetch_path_shares_one_user_agent(self):
+ self.assertEqual(feed_utils.FEED_HEADERS["User-Agent"], USER_AGENT)
+ self.assertEqual(enrichment._UA_HEADERS["User-Agent"], USER_AGENT)
+ self.assertEqual(enrichment._HTTP_SESSION.headers["User-Agent"], USER_AGENT)
+ self.assertEqual(ailabs._SESSION.headers["User-Agent"], USER_AGENT)
+
+ def test_user_agent_clears_the_known_block_line(self):
+ # news.hada.io는 Chrome 메이저 버전을 본다. 124는 403, 128 이상은 통과(2026-08-09).
+ major = int(USER_AGENT.split("Chrome/")[1].split(".")[0])
+ self.assertGreaterEqual(major, 128)
+
+
+class RenderJoinTimeoutTests(unittest.TestCase):
+ def test_hung_render_thread_does_not_block_forever(self):
+ def _hang(url, timeout_ms): # pylint: disable=unused-argument
+ time.sleep(5)
+ return "never"
+
+ async def _call():
+ return enrichment._fetch_rendered_html(
+ "https://example.com", timeout_ms=100
+ )
+
+ with (
+ patch.object(enrichment, "_fetch_rendered_html_sync", _hang),
+ patch.object(enrichment, "RENDER_JOIN_GRACE_SECONDS", 0.2),
+ patch("builtins.print"),
+ ):
+ started = time.monotonic()
+ result = asyncio.run(_call())
+ elapsed = time.monotonic() - started
+
+ self.assertIsNone(result, "상한을 넘긴 렌더는 포기하고 None을 돌려준다")
+ self.assertLess(elapsed, 3, "join이 무기한 기다리면 크롤 프로세스가 멈춘다")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_huggingface_daily_window.py b/tests/test_huggingface_daily_window.py
new file mode 100644
index 0000000..0b22e2e
--- /dev/null
+++ b/tests/test_huggingface_daily_window.py
@@ -0,0 +1,96 @@
+"""HF Daily Papers가 큐레이션 날짜로 창을 자르는지 검증한다.
+
+`publishedAt`은 arXiv 발행일이라 큐레이션보다 며칠에서 몇 주 앞선다. 그 값으로
+`--days` 창을 자르면 오늘 올라온 논문이 통째로 걸러져 매일 0건이 나온다
+(2026-08-10 실측: 목록의 publishedAt 최댓값이 5일 전이라 3일 창에서 0건).
+"""
+
+import asyncio
+import unittest
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+from skim_core.crawlers.feed.huggingface import HuggingFaceCrawler
+
+
+def _iso(dt):
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
+
+
+def _paper(title, published_at, submitted_on_daily_at):
+ return {
+ "title": title,
+ "publishedAt": _iso(published_at),
+ "summary": "abstract text",
+ "thumbnail": "",
+ "numComments": 0,
+ "paper": {
+ "id": title.lower(),
+ "authors": [{"name": "Author One"}],
+ "publishedAt": _iso(published_at),
+ "submittedOnDailyAt": _iso(submitted_on_daily_at)
+ if submitted_on_daily_at
+ else None,
+ },
+ }
+
+
+class FakeResponse:
+ def __init__(self, payload):
+ self._payload = payload
+
+ def raise_for_status(self):
+ return None
+
+ def json(self):
+ return self._payload
+
+
+class HuggingFaceDailyWindowTests(unittest.TestCase):
+ def _crawl(self, payload, since):
+ with patch(
+ "skim_core.crawlers.feed.huggingface.requests.get",
+ return_value=FakeResponse(payload),
+ ):
+ return asyncio.run(
+ HuggingFaceCrawler().crawl(since=since, no_content=True, count=50)
+ )
+
+ def test_keeps_paper_curated_today_even_when_published_weeks_ago(self):
+ now = datetime.now(timezone.utc)
+ payload = [_paper("Fresh", now - timedelta(days=21), now - timedelta(hours=2))]
+
+ posts = self._crawl(payload, since=now - timedelta(days=3))
+
+ self.assertEqual([p.title for p in posts], ["Fresh"])
+
+ def test_drops_paper_curated_before_the_window(self):
+ now = datetime.now(timezone.utc)
+ payload = [_paper("Stale", now - timedelta(hours=2), now - timedelta(days=10))]
+
+ posts = self._crawl(payload, since=now - timedelta(days=3))
+
+ self.assertEqual(posts, [], "큐레이션 날짜가 창 밖이면 제외한다")
+
+ def test_falls_back_to_published_at_when_curation_date_is_missing(self):
+ now = datetime.now(timezone.utc)
+ payload = [_paper("NoDaily", now - timedelta(hours=2), None)]
+
+ posts = self._crawl(payload, since=now - timedelta(days=3))
+
+ self.assertEqual([p.title for p in posts], ["NoDaily"])
+
+ def test_curation_date_is_kept_on_the_post(self):
+ now = datetime.now(timezone.utc)
+ curated = now - timedelta(hours=5)
+ payload = [_paper("Tagged", now - timedelta(days=9), curated)]
+
+ post = self._crawl(payload, since=now - timedelta(days=3))[0]
+
+ self.assertTrue(getattr(post, "submitted_on_daily_at", ""))
+ # timestamp는 논문 발행일 그대로 둔다 (기존 행과 축을 맞춘다).
+ self.assertIn((now - timedelta(days=9)).strftime("%Y-%m-%d"), post.timestamp)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_new_sources.py b/tests/test_new_sources.py
new file mode 100644
index 0000000..3620573
--- /dev/null
+++ b/tests/test_new_sources.py
@@ -0,0 +1,299 @@
+"""소스 확장 회귀 테스트: arxiv 다중 카테고리, HN 다중 피드, lobsters, bluesky."""
+
+import asyncio
+import unittest
+from datetime import datetime, timedelta, timezone
+from unittest.mock import MagicMock, patch
+
+from skim_core.crawlers import REGISTRY
+from skim_core.crawlers.feed import bluesky
+from skim_core.crawlers.feed.arxiv import ArxivCrawler
+from skim_core.crawlers.feed.bluesky import BlueskyCrawler, post_web_url
+from skim_core.crawlers.feed.hackernews import HackerNewsCrawler
+from skim_core.crawlers.feed.lobsters import (
+ LobstersCrawler,
+ _short_id,
+ comment_section_from,
+)
+from skim_core.feed_config import (
+ ARXIV_CATEGORIES,
+ HACKERNEWS_FEEDS,
+ PERSONAL_BLOGS,
+ arxiv_api_url,
+)
+
+
+class FeedConfigTests(unittest.TestCase):
+ def test_arxiv_urls_use_https(self):
+ # export.arxiv.org는 http를 리다이렉트하고, feedparser가 그 과정에서
+ # 조용히 빈 피드를 돌려주는 경우가 있다.
+ for category in ARXIV_CATEGORIES:
+ self.assertTrue(arxiv_api_url(category).startswith("https://"))
+ self.assertIn(f"cat:{category}", arxiv_api_url(category))
+
+ def test_show_and_ask_feeds_have_no_score_gate(self):
+ # Ask/Show HN은 링크가 아니라 본문이 알맹이라 30점 문턱에 걸리면 사라진다.
+ for name in ("hackernews/show", "hackernews/ask"):
+ self.assertNotIn("points=", HACKERNEWS_FEEDS[name])
+
+ def test_undated_feed_is_not_registered(self):
+ # 요즘IT는 피드에 발행일이 없어 fetch_feed가 전량 스킵한다. 등록하면 매번 0건.
+ self.assertNotIn("yozm.wishket.com", " ".join(PERSONAL_BLOGS.values()))
+
+ def test_new_platforms_are_in_the_registry(self):
+ for name in ("lobsters", "bluesky"):
+ self.assertIn(name, REGISTRY)
+
+
+def _arxiv_entry(title, link, published):
+ return {
+ "title": title,
+ "link": link,
+ "published": published,
+ "summary": "abstract",
+ "authors": [{"name": "A"}],
+ }
+
+
+class ArxivMultiCategoryTests(unittest.TestCase):
+ def test_cross_listed_paper_is_collected_once(self):
+ now = datetime.now(timezone.utc)
+ stamp = now.strftime("%Y-%m-%dT%H:%M:%SZ")
+ shared = _arxiv_entry("Shared", "https://arxiv.org/abs/1", stamp)
+ unique = _arxiv_entry("Unique", "https://arxiv.org/abs/2", stamp)
+
+ def fake_parse(url):
+ feed = MagicMock()
+ # cs.AI와 cs.LG 양쪽에 같은 논문이 올라온 상황
+ feed.entries = (
+ [shared] if "cs.CL" in url or "cs.CV" in url else [shared, unique]
+ )
+ return feed
+
+ with patch("skim_core.crawlers.feed.arxiv.feedparser.parse", fake_parse):
+ posts = asyncio.run(
+ ArxivCrawler().crawl(
+ since=now - timedelta(days=2), no_content=True, count=50
+ )
+ )
+
+ urls = [p.url for p in posts]
+ self.assertEqual(
+ len(urls), len(set(urls)), "교차 등록 논문이 중복 저장되면 안 된다"
+ )
+ self.assertEqual(
+ sorted(urls), ["https://arxiv.org/abs/1", "https://arxiv.org/abs/2"]
+ )
+
+ def test_category_is_recorded_on_the_post(self):
+ now = datetime.now(timezone.utc)
+ stamp = now.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ def fake_parse(url):
+ feed = MagicMock()
+ feed.entries = (
+ [_arxiv_entry("Only CV", "https://arxiv.org/abs/9", stamp)]
+ if "cs.CV" in url
+ else []
+ )
+ return feed
+
+ with patch("skim_core.crawlers.feed.arxiv.feedparser.parse", fake_parse):
+ posts = asyncio.run(
+ ArxivCrawler().crawl(
+ since=now - timedelta(days=2), no_content=True, count=50
+ )
+ )
+
+ self.assertEqual(getattr(posts[0], "arxiv_category"), "cs.CV")
+
+
+class HackerNewsMultiFeedTests(unittest.TestCase):
+ def test_same_story_from_two_feeds_is_kept_once(self):
+ now = datetime.now(timezone.utc)
+
+ def fake_fetch(url, name, since): # pylint: disable=unused-argument
+ common = {
+ "url": "https://example.com/a",
+ "title": "Show HN: thing",
+ "published": now.isoformat(),
+ "external_id": "x",
+ "author": "a",
+ "content_html": "",
+ "summary": "",
+ }
+ # 점수가 오른 Show HN은 newest에도 올라온다.
+ return [common] if "show" in url or "newest" in url else []
+
+ with patch("skim_core.crawlers.feed.hackernews.fetch_feed", fake_fetch):
+ posts = asyncio.run(
+ HackerNewsCrawler().crawl(
+ since=now - timedelta(days=1), no_content=True
+ )
+ )
+
+ self.assertEqual(len(posts), 1)
+
+ def test_every_configured_feed_is_queried(self):
+ now = datetime.now(timezone.utc)
+ seen = []
+
+ def fake_fetch(url, name, since): # pylint: disable=unused-argument
+ seen.append(url)
+ return []
+
+ with patch("skim_core.crawlers.feed.hackernews.fetch_feed", fake_fetch):
+ asyncio.run(
+ HackerNewsCrawler().crawl(
+ since=now - timedelta(days=1), no_content=True
+ )
+ )
+
+ self.assertEqual(set(seen), set(HACKERNEWS_FEEDS.values()))
+
+
+LOBSTERS_PAYLOAD = {
+ "short_id": "abc123",
+ "comment_count": 2,
+ "description_plain": "self post body",
+ "comments": [
+ {
+ "comment_plain": "top level",
+ "commenting_user": "alice",
+ "score": 4,
+ "depth": 0,
+ "created_at": "2026-08-09T08:00:00.000-05:00",
+ },
+ {
+ "comment_plain": "a reply",
+ "commenting_user": "bob",
+ "score": 1,
+ "depth": 1,
+ "created_at": "2026-08-09T09:00:00.000-05:00",
+ },
+ {
+ "comment_plain": "gone",
+ "commenting_user": "x",
+ "is_deleted": True,
+ "depth": 0,
+ "created_at": "2026-08-09T09:00:00.000-05:00",
+ },
+ ],
+}
+
+
+class LobstersTests(unittest.TestCase):
+ def test_short_id_comes_from_the_feed_external_id(self):
+ # fetch_feed는 guid를 external_id 키로 넘긴다. guid/id로 읽으면 항상 None이라
+ # 댓글이 한 건도 안 붙는다.
+ self.assertEqual(
+ _short_id({"external_id": "https://lobste.rs/s/rsztog"}), "rsztog"
+ )
+ self.assertIsNone(_short_id({"external_id": "https://lobste.rs/t/zig"}))
+ self.assertIsNone(_short_id({}))
+
+ def test_renders_depth_and_skips_deleted(self):
+ section = comment_section_from(LOBSTERS_PAYLOAD)
+
+ self.assertIn("## Lobsters Comments", section)
+ self.assertIn("- **alice** (4 points,", section)
+ self.assertIn(" - **bob** (1 point,", section)
+ self.assertNotIn("gone", section)
+
+ def test_description_fills_the_body_when_extraction_failed(self):
+ crawler = LobstersCrawler()
+ post = MagicMock(content_markdown="", content="")
+ items = [{"external_id": "https://lobste.rs/s/abc123"}]
+
+ with (
+ patch(
+ "skim_core.crawlers.feed.lobsters.fetch_item",
+ return_value=LOBSTERS_PAYLOAD,
+ ),
+ patch("skim_core.crawlers.feed.lobsters.time.sleep"),
+ patch("skim_core.crawlers.feed.lobsters.typer.echo"),
+ ):
+ crawler.attach_details([post], items)
+
+ self.assertIn("self post body", post.content_markdown)
+ self.assertIn("## Lobsters Comments", post.content_markdown)
+
+ def test_item_fetch_failure_keeps_the_post(self):
+ crawler = LobstersCrawler()
+ post = MagicMock(content_markdown="original", content="original")
+ items = [{"external_id": "https://lobste.rs/s/abc123"}]
+
+ with (
+ patch(
+ "skim_core.crawlers.feed.lobsters.fetch_item",
+ side_effect=ValueError("bad json"),
+ ),
+ patch("skim_core.crawlers.feed.lobsters.time.sleep"),
+ patch("skim_core.crawlers.feed.lobsters.typer.echo"),
+ ):
+ crawler.attach_details([post], items)
+
+ self.assertEqual(post.content_markdown, "original")
+
+
+def _bsky_post(
+ text, handle="a.bsky.social", uri="at://did:plc:x/app.bsky.feed.post/abc"
+):
+ return {
+ "uri": uri,
+ "author": {"handle": handle, "displayName": "A"},
+ "record": {"text": text, "createdAt": "2026-08-09T10:00:00.000Z"},
+ "likeCount": 3,
+ "replyCount": 2,
+ "repostCount": 1,
+ }
+
+
+class BlueskyTests(unittest.TestCase):
+ def test_at_uri_becomes_a_web_url(self):
+ self.assertEqual(
+ post_web_url("at://did:plc:x/app.bsky.feed.post/3mse", "bsky.app"),
+ "https://bsky.app/profile/bsky.app/post/3mse",
+ )
+ self.assertEqual(post_web_url("", "bsky.app"), "")
+
+ def test_reposts_are_skipped(self):
+ crawler = BlueskyCrawler()
+ response = MagicMock(status_code=200)
+ response.json.return_value = {
+ "feed": [
+ {"post": _bsky_post("mine")},
+ {"post": _bsky_post("someone else"), "reason": {"$type": "repost"}},
+ ]
+ }
+ response.raise_for_status.return_value = None
+
+ with patch.object(bluesky._SESSION, "get", return_value=response):
+ posts = crawler.fetch_author_feed(
+ "a.bsky.social", datetime(2026, 1, 1, tzinfo=timezone.utc)
+ )
+
+ self.assertEqual([p.content for p in posts], ["mine"])
+
+ def test_author_self_replies_are_not_treated_as_discussion(self):
+ crawler = BlueskyCrawler()
+ response = MagicMock(status_code=200)
+ response.json.return_value = {
+ "thread": {
+ "post": _bsky_post("root", handle="me.bsky.social"),
+ "replies": [
+ {"post": _bsky_post("my own follow-up", handle="me.bsky.social")},
+ {"post": _bsky_post("someone replied", handle="other.bsky.social")},
+ ],
+ }
+ }
+
+ with patch.object(bluesky._SESSION, "get", return_value=response):
+ section = crawler.fetch_reply_section("at://x")
+
+ self.assertIn("someone replied", section)
+ self.assertNotIn("my own follow-up", section)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_post_state.py b/tests/test_post_state.py
new file mode 100644
index 0000000..3d329e2
--- /dev/null
+++ b/tests/test_post_state.py
@@ -0,0 +1,104 @@
+"""소비 상태(읽음/보관) 회귀 테스트.
+
+`feedback` 테이블은 스키마와 `add_feedback()`만 있고 호출자가 0개, 행이 0개인 채로
+남아 있었다. 하루 200건대가 들어오는데 어디까지 봤는지 표시할 데가 없었다.
+새 컬럼 대신 이 테이블을 쓴다.
+"""
+
+import sqlite3
+import tempfile
+import unittest
+from pathlib import Path
+
+import typer
+
+import skim_cli.cli as main
+from skim_core.db import init_db, post_states, save_posts, set_post_state
+from skim_core.models import Post
+
+
+def _post(external_id, **overrides):
+ data = {
+ "platform": "blogs",
+ "author": "a",
+ "content": "",
+ "content_markdown": "body",
+ "title": f"T{external_id}",
+ "timestamp": "2026-08-10T00:00:00+00:00",
+ "url": f"https://example.com/{external_id}",
+ "external_id": external_id,
+ }
+ data.update(overrides)
+ return Post(**data)
+
+
+class PostStateTests(unittest.TestCase):
+ def setUp(self):
+ self.temp_dir = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp_dir.cleanup)
+ self.db_path = Path(self.temp_dir.name) / "skim.db"
+ init_db(self.db_path)
+ save_posts([_post("1"), _post("2"), _post("3")], "blogs", db_path=self.db_path)
+
+ def _ids(self):
+ conn = sqlite3.connect(self.db_path)
+ ids = [row[0] for row in conn.execute("SELECT id FROM posts ORDER BY id")]
+ conn.close()
+ return ids
+
+ def test_marking_read_is_visible(self):
+ first = self._ids()[0]
+ set_post_state(first, "read", self.db_path)
+
+ self.assertEqual(post_states(self.db_path), {first: "read"})
+
+ def test_state_is_single_valued(self):
+ # read -> archived로 바꾸면 앞의 값이 남으면 안 된다.
+ first = self._ids()[0]
+ set_post_state(first, "read", self.db_path)
+ set_post_state(first, "archived", self.db_path)
+
+ self.assertEqual(post_states(self.db_path), {first: "archived"})
+ conn = sqlite3.connect(self.db_path)
+ rows = conn.execute("SELECT COUNT(*) FROM feedback").fetchone()[0]
+ conn.close()
+ self.assertEqual(rows, 1)
+
+ def test_none_clears_the_state(self):
+ first = self._ids()[0]
+ set_post_state(first, "read", self.db_path)
+ set_post_state(first, None, self.db_path)
+
+ self.assertEqual(post_states(self.db_path), {})
+
+ def test_unknown_state_is_rejected(self):
+ with self.assertRaises(ValueError):
+ set_post_state(self._ids()[0], "starred", self.db_path)
+
+ def test_unread_filter_excludes_marked_posts(self):
+ ids = self._ids()
+ set_post_state(ids[0], "read", self.db_path)
+ set_post_state(ids[1], "archived", self.db_path)
+
+ unread = main._recent_posts(self.db_path, 7, None, 10, unread_only=True)
+ every = main._recent_posts(self.db_path, 7, None, 10)
+
+ self.assertEqual(len(every), 3)
+ self.assertEqual([row["external_id"] for row in unread], ["3"])
+
+ def test_cli_rejects_an_unknown_state(self):
+ with self.assertRaises(typer.Exit) as ctx:
+ main.mark_posts(post_ids=[1], state="starred", db=self.db_path)
+ self.assertEqual(ctx.exception.exit_code, 2)
+
+ def test_cli_marks_and_unmarks(self):
+ ids = self._ids()
+ main.mark_posts(post_ids=ids[:2], state="read", db=self.db_path)
+ self.assertEqual(len(post_states(self.db_path)), 2)
+
+ main.mark_posts(post_ids=ids[:2], state="unread", db=self.db_path)
+ self.assertEqual(post_states(self.db_path), {})
+
+
+if __name__ == "__main__":
+ unittest.main()