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()