lftr
独立的异步 Python LOFTER 抓取与解析库,Python 3.10+。安装名为 lftr,Python 导入名为 lofter。
这是非官方客户端,使用 LOFTER app 端 JSON 接口,与 LOFTER 官方无隶属关系。不依赖 AstrBot,也不包含订阅数据库、消息发送或统计条件管理。
安装
PyPI 安装名为 lftr。首次发行成功后可使用:
python -m pip install lftr
安装名与导入名不同:代码仍使用 from lofter import LofterClient,不要执行 pip install lofter。
从有访问权限的 GitHub 仓库安装开发版本:
python -m pip install "git+ssh://git@github.com/wdcyxxycdw/lofter.git@main"
正式接入其他项目时,建议将 main 换为已经验证的完整提交号。
从源码开发或构建 wheel:
uv sync --locked
uv build
python -m pip install dist/lftr-0.2.0-py3-none-any.whl
快速开始
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:
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 |
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。
视频与合集
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 静态检查:
uv run --locked ruff check .
每次 push/PR 都会运行独立的 lint job,检查 E4、E7、E9 和 F 规则,覆盖源码与测试中的导入位置、语法和未使用变量等问题;不自动修改或格式化代码。
默认只运行合成样本与本地 HTTP/TLS 服务,不访问真实 LOFTER:
uv run --locked pytest -q
显式开启真实网络测试:
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,基准提交 3606b03e4c1689fd5bbff87b3b857a5ba2674a3f。原插件 metadata 标注作者为 yamanashi,保留原作者及贡献者归属,详见 NOTICE。
沿用原项目 README 中的 AGPLv3 声明,本包为 AGPL-3.0-only,完整文本见 LICENSE。本次独立包提取不修改原插件的部署或数据库;插件侧依赖迁移另行进行。
Metadata
Release files for lftr 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| lftr-0.2.0.tar.gz | 34.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lftr-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 67.7 kB
Release files / lftr-0.2.0.tar.gz
| Download URL | lftr-0.2.0.tar.gz |
|---|---|
| Size | 34.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d36a88cf996798e044af1570a7fde8f747208d58e606a263138c9319fae60692
|
|
BLAKE2b-256 checksum How to use checksums |
df6d17cd8c9f5927adcadc1a4e81a18396b10e99bc180fbe5bbafbc6f1df4637
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / lftr-0.2.0-py3-none-any.whl
| Download URL | lftr-0.2.0-py3-none-any.whl |
|---|---|
| Size | 33.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
af337d14edbfc47fe3ad6103932ad42baf6adc86e7d90e53037f17f8feb0c17d
|
|
BLAKE2b-256 checksum How to use checksums |
181acc06a4e10fa1e620b5730d92519bc1f80b18a08b1221ad459405b5e1cce3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|