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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
.codex/
.omx/
.turbo/
pnpm-lock.yaml
tmp/
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ when needed.
- The first end-to-end slice is implemented as the current working slice.
- `apps/api` now ingests feeds into PostgreSQL on boot and serves persisted
article list and detail endpoints.
- `apps/api` now also attempts best-effort article body extraction during
ingestion and stores markdown internally for later summarization work.
- `apps/web` renders the reader UI and consumes the API over HTTP.
- Shared UI primitives live in `packages/ui`.

Expand Down Expand Up @@ -144,6 +146,17 @@ tests handle the test database internally through `TEST_DATABASE_URL`; those
test-database operations are intentionally not exposed as developer-facing
commands.

Article body storage is now part of the normal ingestion lifecycle rather than a
manual script. If one persisted article needs a repair pass, use the narrow API
endpoint:

```bash
curl -X POST http://127.0.0.1:3000/article-content/<article-id>/retry
```

That endpoint is a secondary repair path only. The public article read APIs
still do not expose stored markdown in this slice.

`pnpm dev` at the repo root no longer applies Prisma migrations implicitly. Run
an explicit API migration command before starting the dev servers whenever your
local schema is behind:
Expand Down
11 changes: 11 additions & 0 deletions README.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ RSSift 是一个基于 Turborepo 的 AI 辅助 RSS 筛选工具单仓库。
- 项目目前处于敏捷、迭代式开发中。
- 已完成第一条端到端切片,作为当前可工作的切片。
- `apps/api` 现在会在启动时把 feed 入库到 PostgreSQL,并提供持久化的文章列表与详情接口。
- `apps/api` 现在也会在 ingestion 期间以 best-effort 方式尝试抽取并落库文章正文 Markdown,为后续摘要能力准备输入层。
- `apps/web` 渲染阅读器界面,并通过 HTTP 消费 API。
- 可复用的 UI 基础组件位于 `packages/ui`。

Expand Down Expand Up @@ -137,6 +138,16 @@ pnpm --filter api db:seed
的开发示例数据,请使用 `db:seed`。测试数据库由测试程序通过
`TEST_DATABASE_URL` 在内部处理,不再作为开发者操作命令暴露。

文章正文落库现在属于正常 ingestion 生命周期,而不是手工脚本。如果某一篇已
持久化文章需要补救性重跑,可以调用这个狭窄接口:

```bash
curl -X POST http://127.0.0.1:3000/article-content/<article-id>/retry
```

这个接口只是 repair path,不是默认工作流。本切片里公开的文章读取 API 仍
然不会暴露已存储的 Markdown。

仓库根目录的 `pnpm dev` 不会再隐式执行 Prisma 迁移。只要本地 schema
落后于迁移历史,请先显式运行 API 迁移命令,再启动开发服务:

Expand Down
15 changes: 13 additions & 2 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

This app owns the PostgreSQL-backed feed ingestion backbone for the repository.
It reads `apps/api/feeds.opml`, ingests feeds on boot with best-effort
isolation, and serves the existing read-only `/articles` contract from
persisted data.
isolation, attempts article body extraction during ingestion, and serves the
existing read-only `/articles` contract from persisted data.

## Local Run

Expand Down Expand Up @@ -57,6 +57,8 @@ The API runs on `http://127.0.0.1:3000` by default.
`FEED_OPML_PATH`, `INGEST_ON_BOOT`, and `PORT`.
- `apps/api/feeds.opml` is the app-owned local subscription input.
- Feed parsing uses `feedsmith`.
- Article body extraction uses `@mozilla/readability`, `jsdom`, and `turndown`
inside `apps/api`.
- Prisma schema, migrations, and generated client stay inside `apps/api`.

## Endpoints
Expand All @@ -76,6 +78,15 @@ The API runs on `http://127.0.0.1:3000` by default.
- `summary`
- `originalUrl`
- Returns `404` for unknown article IDs.
- `POST /article-content/:id/retry`
- Re-runs article body extraction for one persisted article as a repair path.
- Returns `{ "status": "succeeded" }`, or a narrow structured
`failed`/`skipped` result when extraction cannot complete.
- Returns `404` for unknown article IDs.

Article body markdown stays internal in this slice. The public `GET /articles`
and `GET /articles/:id` payloads remain unchanged even though
`contentMarkdown` and `contentExtractedAt` are now stored on `Article`.

## Validation

Expand Down
124 changes: 124 additions & 0 deletions apps/api/e2e/article-content-retry.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
jest,
} from "@jest/globals";
import request from "supertest";

import { AppModule } from "../src/app.module";
import {
createTestPrismaClient,
prepareTestDatabase,
} from "../test-support/database";

describe("Article content retry endpoint (e2e)", () => {
let app: INestApplication;
let prisma: ReturnType<typeof createTestPrismaClient>;

beforeAll(async () => {
process.env["TEST_DATABASE_URL"] ??=
"postgresql://rssift:rssift@127.0.0.1:5432/rssift_test";
process.env["DATABASE_URL"] = process.env["TEST_DATABASE_URL"];
process.env["INGEST_ON_BOOT"] = "false";

await prepareTestDatabase();
prisma = createTestPrismaClient();

const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();

app = moduleFixture.createNestApplication();
await app.init();
});

beforeEach(async () => {
await prisma.article.deleteMany();
await prisma.feed.deleteMany();
jest.restoreAllMocks();

const feed = await prisma.feed.create({
data: {
feedUrl: "https://example.com/feed.xml",
siteTitle: "Example feed",
},
});

await prisma.article.create({
data: {
contentMarkdown: "# Old content",
feedId: feed.id,
identityHash: "hash-1",
identitySourceType: "SOURCE_ID",
identitySourceValue: "guid-1",
ingestedAt: new Date("2026-04-15T10:00:00.000Z"),
originalUrl: "https://example.com/articles/1",
publishedAt: new Date("2026-04-15T10:00:00.000Z"),
sourceId: "guid-1",
summary: "Summary 1",
title: "Article 1",
},
});
});

afterAll(async () => {
await app.close();
await prisma.$disconnect();
});

it("retries extraction for a single article and persists refreshed markdown", async () => {
jest.spyOn(global, "fetch").mockResolvedValue(
new Response(
`<!doctype html>
<html>
<body>
<article>
<h1>Article 1</h1>
<p>Freshly retried body content for this article.</p>
</article>
</body>
</html>`,
{ status: 200 },
),
);

const article = await prisma.article.findFirstOrThrow({
orderBy: {
id: "asc",
},
});
const server = app.getHttpServer() as Parameters<typeof request>[0];
const response = await request(server)
.post(`/article-content/${article.id}/retry`)
.expect(200);

expect(response.body).toEqual({
status: "succeeded",
});

const updated = await prisma.article.findUniqueOrThrow({
where: {
id: article.id,
},
});

expect(updated.contentMarkdown).toBe(
"# Article 1\n\nFreshly retried body content for this article.",
);
expect(updated.contentExtractedAt).toBeInstanceOf(Date);
});

it("returns 404 for an unknown article id", async () => {
const server = app.getHttpServer() as Parameters<typeof request>[0];

await request(server)
.post("/article-content/unknown-article-id/retry")
.expect(404);
});
});
8 changes: 7 additions & 1 deletion apps/api/e2e/articles.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe("Articles endpoints (e2e)", () => {
process.env["TEST_DATABASE_URL"] ??=
"postgresql://rssift:rssift@127.0.0.1:5432/rssift_test";
process.env["DATABASE_URL"] = process.env["TEST_DATABASE_URL"];
process.env["INGEST_ON_BOOT"] ??= "false";
process.env["INGEST_ON_BOOT"] = "false";

await prepareTestDatabase();
prisma = createTestPrismaClient();
Expand Down Expand Up @@ -65,6 +65,8 @@ describe("Articles endpoints (e2e)", () => {
identityHash: "hash-1",
identitySourceType: "SOURCE_ID",
identitySourceValue: "guid-1",
contentExtractedAt: new Date("2026-04-15T10:05:00.000Z"),
contentMarkdown: "# Article 1\n\nPersisted body",
ingestedAt: new Date("2026-04-15T10:00:00.000Z"),
originalUrl: "https://example.com/articles/1",
publishedAt: new Date("2026-04-15T10:00:00.000Z"),
Expand Down Expand Up @@ -106,6 +108,8 @@ describe("Articles endpoints (e2e)", () => {
"sourceTitle",
"title",
]);
expect(list[0]).not.toHaveProperty("contentMarkdown");
expect(list[0]).not.toHaveProperty("contentExtractedAt");
});

it("GET /articles/:id returns detail payload shape", async () => {
Expand All @@ -126,6 +130,8 @@ describe("Articles endpoints (e2e)", () => {
"summary",
"title",
]);
expect(detail).not.toHaveProperty("contentMarkdown");
expect(detail).not.toHaveProperty("contentExtractedAt");
});

it("GET /articles/:id returns 404 for unknown article id", async () => {
Expand Down
Loading