Metadata-Version: 2.4
Name: lftr
Version: 0.2.0
Summary: Async LOFTER client with DWR search and HTML parsing
License-Expression: AGPL-3.0-only
License-File: LICENSE
License-File: NOTICE
Requires-Dist: aiohttp>=3.8.0
Requires-Dist: beautifulsoup4>=4.11.0
Requires-Dist: lxml>=4.9.0
Requires-Dist: dukpy>=0.5.1
Requires-Python: >=3.10
Project-URL: Repository, https://github.com/wdcyxxycdw/lofter
Project-URL: Issues, https://github.com/wdcyxxycdw/lofter/issues
Description-Content-Type: text/markdown

# lftr

独立的异步 Python LOFTER 抓取与解析库，Python **3.10+**。安装名为 `lftr`，Python 导入名为 `lofter`。

这是非官方客户端，使用 LOFTER app 端 JSON 接口，与 LOFTER 官方无隶属关系。不依赖 AstrBot，也不包含订阅数据库、消息发送或统计条件管理。

## 安装

PyPI 安装名为 `lftr`。首次发行成功后可使用：

```sh
python -m pip install lftr
```

安装名与导入名不同：代码仍使用 `from lofter import LofterClient`，不要执行 `pip install lofter`。

从有访问权限的 GitHub 仓库安装开发版本：

```sh
python -m pip install "git+ssh://git@github.com/wdcyxxycdw/lofter.git@main"
```

正式接入其他项目时，建议将 `main` 换为已经验证的完整提交号。

从源码开发或构建 wheel：

```sh
uv sync --locked
uv build
python -m pip install dist/lftr-0.2.0-py3-none-any.whl
```

## 快速开始

```python
import asyncio

from lofter import LofterClient


async def main():
    async with LofterClient() as client:
        posts = await client.fetch_tag_posts("原创")
        for post in posts:
            print(post.post_id, post.title, post.url)


asyncio.run(main())
```

抓取公开内容不需要 Cookie。`async with` 会复用 HTTP 连接并在结束时关闭，也可手动调用 `await client.close()`。单个客户端的请求起始时间至少间隔 0.3 秒；连接错误、超时及 HTTP 429/500/502/503/504 最多尝试 3 次，其他 HTTP 错误直接抛出。

### 错误语义

app 接口的 HTTP 状态码恒为 200，真实状态在响应体的 `meta.status`：

| `meta.status` | 行为 |
|---|---|
| `200` | 正常 |
| `401` | 抛出 `LofterAuthError`（继承 `RuntimeError`） |
| 其他（常见 `4200`） | 抛出 `RuntimeError`。`4200` 是通用错误码，参数不合法、method 名无效与内容不可见都会返回它，其 `msg` 文案不可直接展示给用户 |

合法的空结果会返回空列表，不会与错误混淆。

### 结构化抓取

返回 `Post`（11 个字段，契约冻结）：

| 方法 | 返回值 | 范围 |
|---|---|---|
| `fetch_tag_posts(tag, offset=0, sort="new")` | `list[Post]` | 一页标签作品 |
| `fetch_tag_posts_paged(tag, total=...)` | `list[Post]` | 按服务端游标翻页，按 ID 去重 |
| `fetch_post(url)` | `Post` | HTTPS 博主单帖详情 |
| `fetch_blog_posts(username, limit=50, offset=0)` | `list[Post]` | 博主作品，已含完整正文与全部图片 |
| `fetch_collection_posts(collection_id, offset=0, limit=20)` | `list[Post]` | 合集内作品 |
| `enrich_posts(posts)` | `list[Post]` | 按原顺序补充详情，保留原 post_id |

`sort` 可取 `new` / `total` / `date` / `week` / `month`，不同取值的结果集互不相同。

标签搜索的每页条数由服务端决定（实测 12 条），`limit` 参数被服务端忽略；`fetch_tag_posts` 仍保留 `limit` 与 `before` 形参以兼容旧签名，但二者不再影响结果。分页请使用 `offset`：

```python
first = await client.fetch_tag_posts("原创")
second = await client.fetch_tag_posts("原创", offset=len(first))
```

`fetch_blog_posts` 的响应已带完整正文与全部图片，因此 `enrich=True` 不再触发额外请求。`enrich_posts` 默认在单篇失败时记录 warning 并保留原帖；需要严格验证时传 `continue_on_error=False`。`fetch_post` 对没有正文、摘要和图片的帖子抛出 `RuntimeError`。

本包不维护订阅游标，不执行“补抓到上次已知帖子”的业务策略，不保证全量枚举。可见范围受账号权限、接口分页和平台风控影响。

### 扩展字段

`Post` 的 11 个字段是冻结契约，新增数据一律通过 `PostDetail` 提供，不会改变 `Post` 的形状：

| 方法 | 返回值 |
|---|---|
| `fetch_tag_posts_detailed(tag, offset=0, sort="new")` | `list[PostDetail]` |
| `fetch_blog_posts_detailed(username, limit=50, offset=0)` | `list[PostDetail]` |
| `fetch_post_detailed(url)` | `PostDetail` |

```python
detail = (await client.fetch_tag_posts_detailed("原创"))[0]
detail.post           # 仍是 11 字段的 Post
detail.post_type      # 1=文字 2=图片 4=视频（3/5/6 未在样本中出现）
detail.word_count     # 字数
detail.is_top         # 是否置顶
detail.create_time_ms # 创建 / 修改 / 编辑时间
detail.modify_time_ms
detail.edit_time_ms
detail.stats.views    # 互动数据：responses/favorites/reblogs/shares/views/subscribes/hot
```

`fetch_post_detailed` 的 `stats.views` 恒为 0，这是单帖接口自身的行为，其余计数正常；需要浏览量请改用 `fetch_blog_posts_detailed` 或 `fetch_tag_posts_detailed`。

### 视频与合集

```python
video = await client.fetch_post_video(url)        # 非视频帖返回 None
video.url, video.hls_url, video.h265_url          # mp4 直链 / HLS / H.265
video.cover_url, video.duration, video.size
video.width, video.height

collections = await client.fetch_collections("someuser")
posts = await client.fetch_collection_posts(collections[0].collection_id)
```

`Collection` 字段：`collection_id`、`name`、`description`、`cover_url`、`tags`、
`post_count`、`view_count`、`hot`、`last_publish_time_ms`。

### 底层与离线解析 API

- `client.get(url, timeout=15)`：原始 HTML/text。
- `client.search_tag(tag, ...)` / `client.search_tag_paged(tag, total)`：保留的原始 DWR 文本出口，仍走 `TagBean.search`，需要有效 Cookie。
- `client.update_cookie(cookie)`：更新后续请求使用的 Cookie。
- `decode_permalink("1d038690_34f54df9c")`：纯本地把 permalink 解成 `(blog_id, post_id)`。

纯解析函数无需联网：`parse_dwr_response`、`parse_post_page`、`parse_blog_posts`、`extract_lofter_username`、`build_tag_search_body`。HTML 与 DWR 解析器保留原行为，但高层抓取已不再经过它们。

## 凭据与边界

- 只使用自己有权访问的内容；本包不自动登录或绕过访问限制。抓取公开内容不需要 Cookie。
- Cookie 仅用于保留的原始 DWR 出口。放在进程环境或仅本机可读、被 Git 忽略的 `.env.test` 中，不写入源码、日志、fixtures 或命令历史。
- 低层 `get()` 会向传入的 URL 发送 Cookie；仅向可信目标调用它。优先使用校验目标格式的高层抓取方法。
- DWR 解析会用 dukpy 执行响应脚本，只应处理可信来源的 LOFTER 响应，不应当作通用的不可信 JavaScript 沙箱。
- 图片与视频字段是 URL，不下载媒体文件。
- **读取会计入浏览量。**通过 app 接口读取作品会使该作品的 `viewCount` 增加。大规模抓取会虚增作者的浏览数据，请据此控制频率与范围。
- 使用的是 LOFTER app 端非公开接口，URL 中的 `product=` 版本号会随 app 升级漂移，接口可能在任何时候变更或失效。

## 兼容性

`Post` 的 11 个字段是冻结契约。新增数据一律走 `PostDetail`、`Video`、`Collection` 等独立类型，
以免 `asdict(post)` / `astuple(post)` / `Post(**stored_dict)` 这类往返用法在调用方侧被破坏。

高层抓取已全量切换到 app JSON 接口，没有回退到 DWR 的路径：数据要么来自 app 接口，要么抛错，
不会出现字段因链路不同而静默为空的情况。原始 DWR 出口（`search_tag`、`search_tag_paged`、
`parse_dwr_response`、`build_tag_search_body`）保持原行为不变。

## 测试

本地运行与 CI 相同的 Ruff 静态检查：

```sh
uv run --locked ruff check .
```

每次 push/PR 都会运行独立的 `lint` job，检查 `E4`、`E7`、`E9` 和 `F` 规则，覆盖源码与测试中的导入位置、语法和未使用变量等问题；不自动修改或格式化代码。

默认只运行合成样本与本地 HTTP/TLS 服务，不访问真实 LOFTER：

```sh
uv run --locked pytest -q
```

显式开启真实网络测试：

```sh
LOFTER_TAG=摄影 uv run --locked pytest -q --live tests/test_live.py
```

`--live` 只要求 `LOFTER_TAG`；缺少时直接报错，不静默跳过。`LOFTER_POST_URL` 和 `LOFTER_BLOG` 是可选的已知样本覆盖，未填写时会从标签搜索结果自动选择单帖 URL 和博主用户名。在线测试会验证标签分页与去重、单帖详情、扩展字段、博主作品与合集抓取；只请求 LOFTER，不调用 AstrBot 或 QQ。

另外提供 `LOFTER_COOKIE` 时会额外验证保留的原始 DWR 出口仍然可达；未提供时该用例跳过。

GitHub Actions 的 `Live LOFTER E2E` 工作流只支持手动触发，不会在普通 push/PR CI 中运行。
CI 在 Python 3.10 和 3.13 上运行离线测试、构建 sdist/wheel，再将 wheel 安装至仓库外的干净虚拟环境重新运行测试，验证没有依赖原插件或源码路径。

## 发布到 PyPI

发布工作流为 `.github/workflows/publish.yml`，仅在 GitHub Release 正式发布时触发，不在提交或 PR 时上传。

在 PyPI 的账户 Publishing 页面添加 pending trusted publisher：

| 字段 | 值 |
|---|---|
| PyPI project name | `lftr` |
| Repository owner | `wdcyxxycdw` |
| Repository name | `lofter` |
| Workflow filename | `publish.yml` |
| Environment | 留空 |

工作流合并到 `main` 后，为对应提交创建 `v0.2.0` Release。工作流会检查标签与 `pyproject.toml` 的版本一致，运行离线测试、构建 `lftr` 的 wheel/sdist，再通过 OIDC Trusted Publishing 上传。构建任务没有发布权限，发布任务不需要长期 API Token 或 LOFTER Cookie。

发布会公开安装包及其中的源码；GitHub 仓库本身无需改为公开。PyPI 的同名版本文件不能覆盖，后续发行必须更新版本及锁文件，再创建对应的新 Release。

## 来源与许可证

提取自 [wdcyxxycdw/astrbot_plugin_lofter](https://github.com/wdcyxxycdw/astrbot_plugin_lofter)，基准提交 `3606b03e4c1689fd5bbff87b3b857a5ba2674a3f`。原插件 metadata 标注作者为 `yamanashi`，保留原作者及贡献者归属，详见 [NOTICE](NOTICE)。

沿用原项目 README 中的 AGPLv3 声明，本包为 **AGPL-3.0-only**，完整文本见 [LICENSE](LICENSE)。本次独立包提取不修改原插件的部署或数据库；插件侧依赖迁移另行进行。
