funread-api 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
funread_api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP API service exposing funread's source-list collection."""
|
funread_api/app.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""FastAPI app assembly (sync — funread's storage layer is sync SQLAlchemy)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import AsyncIterator
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
|
|
10
|
+
from funread.legado.manage.source.storage import init_source_db
|
|
11
|
+
from funread_api.v1 import api_router
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@asynccontextmanager
|
|
15
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
16
|
+
# Create tables (if missing) on startup so config/connectivity errors
|
|
17
|
+
# surface immediately instead of on the first request.
|
|
18
|
+
init_source_db()
|
|
19
|
+
yield
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_app() -> FastAPI:
|
|
23
|
+
app = FastAPI(
|
|
24
|
+
title="funread",
|
|
25
|
+
version="0.1.0",
|
|
26
|
+
description="Manage funread's source-list collection",
|
|
27
|
+
lifespan=lifespan,
|
|
28
|
+
)
|
|
29
|
+
app.include_router(api_router, prefix="/api/v1")
|
|
30
|
+
|
|
31
|
+
@app.get("/healthz", tags=["ops"])
|
|
32
|
+
def healthz() -> dict:
|
|
33
|
+
return {"status": "ok"}
|
|
34
|
+
|
|
35
|
+
return app
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
app = create_app()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run() -> None:
|
|
42
|
+
import uvicorn
|
|
43
|
+
|
|
44
|
+
uvicorn.run("funread_api.app:app", host="0.0.0.0", port=18811)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
run()
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Source-list registration, management, and manual collection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from fastapi import APIRouter, HTTPException, Query, status
|
|
10
|
+
from pydantic import AnyHttpUrl, BaseModel
|
|
11
|
+
from sqlalchemy import func, select
|
|
12
|
+
from sqlalchemy.orm import Session
|
|
13
|
+
|
|
14
|
+
from funread.legado.manage.source.storage import (
|
|
15
|
+
SourceListRecord,
|
|
16
|
+
count_source_items,
|
|
17
|
+
count_source_payloads,
|
|
18
|
+
fetch_source_list_data,
|
|
19
|
+
get_session_factory,
|
|
20
|
+
utcnow,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
router = APIRouter(prefix="/sources", tags=["sources"])
|
|
24
|
+
SourceType = Literal["book", "rss"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SourceCreate(BaseModel):
|
|
28
|
+
url: AnyHttpUrl
|
|
29
|
+
source_type: SourceType | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SourceUpdate(BaseModel):
|
|
33
|
+
enabled: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SourceListRecordOut(BaseModel):
|
|
37
|
+
id: int
|
|
38
|
+
url: str
|
|
39
|
+
source_type: SourceType
|
|
40
|
+
source_count: int
|
|
41
|
+
enabled: bool
|
|
42
|
+
consecutive_failures: int
|
|
43
|
+
last_error: str | None
|
|
44
|
+
last_success_at: datetime | None
|
|
45
|
+
increment_start: int | None
|
|
46
|
+
increment_stop: int | None
|
|
47
|
+
last_queried_at: datetime
|
|
48
|
+
created_at: datetime
|
|
49
|
+
updated_at: datetime
|
|
50
|
+
|
|
51
|
+
model_config = {"from_attributes": True}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SourceListPage(BaseModel):
|
|
55
|
+
items: list[SourceListRecordOut]
|
|
56
|
+
total: int
|
|
57
|
+
limit: int
|
|
58
|
+
offset: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CollectReport(BaseModel):
|
|
62
|
+
source_id: int
|
|
63
|
+
ok: bool
|
|
64
|
+
source_count: int
|
|
65
|
+
error: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_or_404(session: Session, source_id: int) -> SourceListRecord:
|
|
69
|
+
record = session.get(SourceListRecord, source_id)
|
|
70
|
+
if record is None:
|
|
71
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="采集源不存在")
|
|
72
|
+
return record
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _download(url: str) -> object:
|
|
76
|
+
response = requests.get(url, timeout=30)
|
|
77
|
+
response.raise_for_status()
|
|
78
|
+
return response.json()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _detect_source_type(payload: object) -> SourceType | None:
|
|
82
|
+
if isinstance(payload, list):
|
|
83
|
+
items = payload
|
|
84
|
+
elif isinstance(payload, dict):
|
|
85
|
+
items = next(
|
|
86
|
+
(payload[key] for key in ("list", "data") if isinstance(payload.get(key), list)),
|
|
87
|
+
[payload],
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
for item in items:
|
|
93
|
+
if not isinstance(item, dict):
|
|
94
|
+
continue
|
|
95
|
+
if "bookSourceUrl" in item or "bookSourceName" in item:
|
|
96
|
+
return "book"
|
|
97
|
+
if "sourceUrl" in item or "sourceName" in item:
|
|
98
|
+
return "rss"
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.get("/supported", response_model=list[SourceType])
|
|
103
|
+
def list_supported() -> list[SourceType]:
|
|
104
|
+
return ["book", "rss"]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.post("", response_model=SourceListRecordOut, status_code=status.HTTP_201_CREATED)
|
|
108
|
+
def create_source(payload: SourceCreate) -> SourceListRecordOut:
|
|
109
|
+
url = str(payload.url)
|
|
110
|
+
session_factory = get_session_factory()
|
|
111
|
+
with session_factory() as session:
|
|
112
|
+
existing = session.scalar(select(SourceListRecord).where(SourceListRecord.url == url))
|
|
113
|
+
if existing is not None:
|
|
114
|
+
raise HTTPException(
|
|
115
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
116
|
+
detail=f"采集源已存在(id={existing.id})",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
source_data = _download(url)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
raise HTTPException(
|
|
123
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
124
|
+
detail=f"无法读取采集源:{exc}",
|
|
125
|
+
) from exc
|
|
126
|
+
|
|
127
|
+
detected = _detect_source_type(source_data)
|
|
128
|
+
source_type = payload.source_type or detected
|
|
129
|
+
if source_type is None:
|
|
130
|
+
raise HTTPException(
|
|
131
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
132
|
+
detail="无法识别采集源类型,请选择书源或 RSS 源",
|
|
133
|
+
)
|
|
134
|
+
if (
|
|
135
|
+
detected is not None
|
|
136
|
+
and payload.source_type is not None
|
|
137
|
+
and detected != payload.source_type
|
|
138
|
+
):
|
|
139
|
+
raise HTTPException(
|
|
140
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
141
|
+
detail=f"采集源内容识别为 {detected},与所选类型 {payload.source_type} 不一致",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
now = utcnow()
|
|
145
|
+
record = SourceListRecord(
|
|
146
|
+
url=url,
|
|
147
|
+
source_type=source_type,
|
|
148
|
+
source_count=count_source_items(source_data),
|
|
149
|
+
last_queried_at=now,
|
|
150
|
+
last_success_at=now,
|
|
151
|
+
)
|
|
152
|
+
session.add(record)
|
|
153
|
+
session.commit()
|
|
154
|
+
session.refresh(record)
|
|
155
|
+
return SourceListRecordOut.model_validate(record)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@router.get("", response_model=SourceListPage)
|
|
159
|
+
def list_sources(
|
|
160
|
+
limit: int = Query(default=50, ge=1, le=500),
|
|
161
|
+
offset: int = Query(default=0, ge=0),
|
|
162
|
+
source_type: SourceType | None = None,
|
|
163
|
+
enabled: bool | None = None,
|
|
164
|
+
keyword: str | None = Query(default=None, min_length=1, max_length=255),
|
|
165
|
+
) -> SourceListPage:
|
|
166
|
+
session_factory = get_session_factory()
|
|
167
|
+
with session_factory() as session:
|
|
168
|
+
filters = []
|
|
169
|
+
if source_type is not None:
|
|
170
|
+
filters.append(SourceListRecord.source_type == source_type)
|
|
171
|
+
if enabled is not None:
|
|
172
|
+
filters.append(SourceListRecord.enabled.is_(enabled))
|
|
173
|
+
if keyword is not None:
|
|
174
|
+
filters.append(SourceListRecord.url.contains(keyword))
|
|
175
|
+
total = session.execute(
|
|
176
|
+
select(func.count()).select_from(SourceListRecord).where(*filters)
|
|
177
|
+
).scalar_one()
|
|
178
|
+
rows = (
|
|
179
|
+
session.execute(
|
|
180
|
+
select(SourceListRecord)
|
|
181
|
+
.where(*filters)
|
|
182
|
+
.order_by(SourceListRecord.id.desc())
|
|
183
|
+
.offset(offset)
|
|
184
|
+
.limit(limit)
|
|
185
|
+
)
|
|
186
|
+
.scalars()
|
|
187
|
+
.all()
|
|
188
|
+
)
|
|
189
|
+
items = [SourceListRecordOut.model_validate(row) for row in rows]
|
|
190
|
+
return SourceListPage(items=items, total=total, limit=limit, offset=offset)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.patch("/{source_id}", response_model=SourceListRecordOut)
|
|
194
|
+
def update_source(source_id: int, payload: SourceUpdate) -> SourceListRecordOut:
|
|
195
|
+
session_factory = get_session_factory()
|
|
196
|
+
with session_factory() as session:
|
|
197
|
+
record = _get_or_404(session, source_id)
|
|
198
|
+
record.enabled = payload.enabled
|
|
199
|
+
session.commit()
|
|
200
|
+
session.refresh(record)
|
|
201
|
+
return SourceListRecordOut.model_validate(record)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@router.delete("/{source_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
|
205
|
+
def delete_source(source_id: int) -> None:
|
|
206
|
+
session_factory = get_session_factory()
|
|
207
|
+
with session_factory() as session:
|
|
208
|
+
session.delete(_get_or_404(session, source_id))
|
|
209
|
+
session.commit()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@router.post(
|
|
213
|
+
"/{source_id}/reset-cursor",
|
|
214
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
215
|
+
response_model=None,
|
|
216
|
+
)
|
|
217
|
+
def reset_source_cursor(source_id: int) -> None:
|
|
218
|
+
session_factory = get_session_factory()
|
|
219
|
+
with session_factory() as session:
|
|
220
|
+
record = _get_or_404(session, source_id)
|
|
221
|
+
record.last_queried_at = datetime(2000, 1, 1)
|
|
222
|
+
record.consecutive_failures = 0
|
|
223
|
+
record.last_error = None
|
|
224
|
+
session.commit()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@router.post("/{source_id}/collect", response_model=CollectReport)
|
|
228
|
+
def collect_source(source_id: int) -> CollectReport:
|
|
229
|
+
session_factory = get_session_factory()
|
|
230
|
+
with session_factory() as session:
|
|
231
|
+
record = _get_or_404(session, source_id)
|
|
232
|
+
record.last_queried_at = utcnow()
|
|
233
|
+
try:
|
|
234
|
+
record.source_count = count_source_payloads(fetch_source_list_data(record))
|
|
235
|
+
record.last_success_at = record.last_queried_at
|
|
236
|
+
record.consecutive_failures = 0
|
|
237
|
+
record.last_error = None
|
|
238
|
+
report = CollectReport(source_id=record.id, ok=True, source_count=record.source_count)
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
record.consecutive_failures += 1
|
|
241
|
+
record.last_error = str(exc)[:2048]
|
|
242
|
+
report = CollectReport(
|
|
243
|
+
source_id=record.id,
|
|
244
|
+
ok=False,
|
|
245
|
+
source_count=record.source_count,
|
|
246
|
+
error=record.last_error,
|
|
247
|
+
)
|
|
248
|
+
session.commit()
|
|
249
|
+
return report
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: funread-api
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: funread 采集源管理后端服务
|
|
5
|
+
Project-URL: Organization, https://github.com/farfarfun
|
|
6
|
+
Project-URL: Repository, https://github.com/farfarfun/funread-api
|
|
7
|
+
Project-URL: Releases, https://github.com/farfarfun/funread-api/releases
|
|
8
|
+
Author-email: 牛哥 <niuliangtao@qq.com>, farfarfun <farfarfun@qq.com>
|
|
9
|
+
Maintainer-email: 牛哥 <niuliangtao@qq.com>, farfarfun <farfarfun@qq.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Requires-Dist: fastapi>=0.110.0
|
|
13
|
+
Requires-Dist: funread>=1.1.101
|
|
14
|
+
Requires-Dist: pydantic>=2.0.0
|
|
15
|
+
Requires-Dist: requests>=2.28.0
|
|
16
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
17
|
+
Requires-Dist: uvicorn[standard]>=0.27.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# funread-api
|
|
26
|
+
|
|
27
|
+
`funread` 的后端服务,依赖 [funread](https://github.com/farfarfun/funread) 核心库并对外暴露 API,供
|
|
28
|
+
[funread-web](https://github.com/farfarfun/funread-web) 调用。搭配使用见
|
|
29
|
+
[funread-dev](https://github.com/farfarfun/funread-dev)。
|
|
30
|
+
|
|
31
|
+
目前处于开发初期,具体接口、部署方式与配置项尚未确定。
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
funread_api/__init__.py,sha256=3kKTNSSYl8RscdaiyGalWr5UsMCPknoBWx-TUKCY8_M,66
|
|
2
|
+
funread_api/app.py,sha256=fRXSJMRhBuWjTBPlz-ZBv5hxd200sW6Z4ySt9d54aTI,1103
|
|
3
|
+
funread_api/v1/__init__.py,sha256=EXuUv3Jd-eBpz3saxorqoK6OuE7L4qd5o5-j-xAqYSY,171
|
|
4
|
+
funread_api/v1/sources.py,sha256=ZMaAZ9EqlBlSu3Mf3YDm8iJZczakhRxPhfmMJc6HynI,8055
|
|
5
|
+
funread_api-0.1.1.dist-info/METADATA,sha256=esQIt45AIBvdIWnGVNuLqrdPmLwr1z97ghUtoVKLOBA,1291
|
|
6
|
+
funread_api-0.1.1.dist-info/WHEEL,sha256=6zicbvgMWfHSD_sSydPAPL1UNY8-QHIQA4J8fOxyAc8,87
|
|
7
|
+
funread_api-0.1.1.dist-info/entry_points.txt,sha256=4LXfMRMLiiEFeQXa8myro44--DQ36pSbvt4FUcQtmpk,52
|
|
8
|
+
funread_api-0.1.1.dist-info/RECORD,,
|