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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 48 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 # 지원 플랫폼 목록
Expand Down Expand Up @@ -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`)를 새로 들지 않는다.
Expand All @@ -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` 호출
Expand All @@ -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 차단선에 걸리므로 함부로 낮추지 않는다
Expand Down
24 changes: 21 additions & 3 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -80,16 +82,32 @@ 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 생성:

```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
Expand Down
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -80,16 +82,32 @@ 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:

```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
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/Sources/SkimDesktopApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/Sources/SkimDesktopCore/DashboardModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down
46 changes: 42 additions & 4 deletions apps/desktop/Sources/SkimDesktopCore/SkimDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions apps/desktop/Tests/SkimDesktopCoreTests/SkimDatabaseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Loading
Loading