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.1-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.1 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.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| lftr-0.2.1.tar.gz | 35.5 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lftr-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 69.2 kB
Release files / lftr-0.2.1.tar.gz
| Download URL | lftr-0.2.1.tar.gz |
|---|---|
| Size | 35.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d00576b6726cfb6868b5fc951358a92ef4780b2eaf2b78259404e19a9dc31cbb
|
|
BLAKE2b-256 checksum How to use checksums |
6cd3ef8619ec2d6b21cce69084c3a5e4d94a457745f94f7d1165f7b77b5186cd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","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.1-py3-none-any.whl
| Download URL | lftr-0.2.1-py3-none-any.whl |
|---|---|
| Size | 33.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ac78f2fa0b23bc2e2cfe550af353958d13c4de343d8f548c882f91fa7afc7934
|
|
BLAKE2b-256 checksum How to use checksums |
5a741bc97fcdc8ee531ad98ebf5a55c412ee0986d054fcf03e6eeb52ae18f3ec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","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}
|
Security for lftr 0.2.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.