Metadata-Version: 2.4
Name: lftr
Version: 0.1.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 网页及内部 DWR 标签搜索接口，与 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.1.0-py3-none-any.whl
```

## 快速开始

```python
import asyncio
import os

from lofter import LofterClient


async def main():
    async with LofterClient(os.environ["LOFTER_COOKIE"]) as client:
        posts = await client.fetch_tag_posts("原创", limit=20)
        for post in posts:
            print(post.post_id, post.title, post.url)


asyncio.run(main())
```

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

### 结构化抓取

| 方法 | 返回值 | 范围 |
|---|---|---|
| `fetch_tag_posts(tag, offset=0, limit=20, before=0)` | `list[Post]` | 一页标签作品 |
| `fetch_tag_posts_paged(tag, total=...)` | `list[Post]` | 最多请求 100 条，按 ID 去重 |
| `fetch_post(url)` | `Post` | HTTPS 博主单帖详情 |
| `fetch_blog_posts(username, enrich=False)` | `list[Post]` | 博主首页链接，可选逐篇补充详情 |
| `enrich_posts(posts, continue_on_error=True)` | `list[Post]` | 按原顺序补充详情，保留原 post_id |

分页示例：

```python
first = await client.fetch_tag_posts("原创")
if first:
    before = min(post.publish_time_ms for post in first)
    second = await client.fetch_tag_posts("原创", offset=20, before=before)
```

分页同时使用位置 `offset` 和上一页最早的毫秒时间戳 `before`。包不维护订阅游标，不执行“补抓到上次已知帖子”的业务策略，不保证全量枚举，也不保证指定 `total` 条都为不同作品；可见范围受账号权限、接口分页和平台风控影响。

`enrich_posts` 默认在单篇失败时记录 warning 并保留原帖，不代表所有详情均成功。需要严格验证时传 `continue_on_error=False`。`fetch_post` 对无正文、摘要和图片的页面抛出 `RuntimeError`，不会把这样的登录页当成成功的帖子。

### 底层与离线解析 API

为便于迁移已有调用方，保留：

- `client.get(url, timeout=15)`：原始 HTML/text。
- `client.search_tag(tag, offset=0, limit=20, before=0)`：原始 DWR 文本。
- `client.search_tag_paged(tag, total)`：原始 DWR 分页文本列表，最多请求 100 条。
- `client.update_cookie(cookie)`：更新后续请求使用的 Cookie。

纯解析函数无需联网：

```python
from lofter import (
    Post,
    build_tag_search_body,
    extract_lofter_username,
    parse_blog_posts,
    parse_dwr_response,
    parse_post_page,
)
```

`Post` 保留原插件的 11 个字段：`post_id`、`title`、`summary`、`images`、`author`、`author_username`、`url`、`tags`、`publish_time`、`content`、`publish_time_ms`。`publish_time` 仍使用本地时区格式化；需要机器游标时应使用 `publish_time_ms`。

HTML 纯解析函数保留原行为：不包含可识别作品的博主页可能返回空列表，单帖解析可能返回空字段；它们不是账号登录状态检测器。HTML 主题差异、登录墙、风控页或站点改版可能导致无法解析。DWR 解析会区分合法空列表、服务端异常、无效结构与非 DWR 响应。

## 凭据与边界

- 只使用自己有权访问的内容和 Cookie；本包不自动登录或绕过访问限制。
- Cookie 放在进程环境或仅本机可读、被 Git 忽略的 `.env.test` 中，不写入源码、日志、fixtures 或命令历史。
- 低层 `get()` 会向传入的 URL 发送 Cookie；仅向可信目标调用它。优先使用校验目标格式的高层抓取方法。
- DWR 解析会用 dukpy 执行响应脚本，只应处理可信来源的 LOFTER 响应，不应当作通用的不可信 JavaScript 沙箱。
- 图片字段是 URL，不下载图片文件；不包含移动端 API，也不承诺作品总量统计。

## 测试

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

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

显式开启真实网络测试：

```sh
cp .env.test.example .env.test
# 仅在本机填写 Cookie 和至少有两页作品的标签
uv run --locked pytest -q --live tests/test_live.py
```

`--live` 要求 `LOFTER_COOKIE` 和 `LOFTER_TAG`；缺少时直接报错，不静默跳过。`LOFTER_POST_URL` 和 `LOFTER_BLOG` 是可选的已知样本覆盖，未填写时会从标签搜索结果自动选择单帖 URL 和博主用户名。在线测试会验证真实 DWR 标签分页、HTML 单帖解析、博主主页解析和至少一篇详情 enrich；只请求 LOFTER，不调用 AstrBot 或 QQ。

GitHub Actions 的 `Live LOFTER E2E` 工作流只支持手动触发，使用仓库 Secret `LOFTER_COOKIE` 和手动输入的标签，不会在普通 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.1.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)。本次独立包提取不修改原插件的部署或数据库；插件侧依赖迁移另行进行。
