linxsci-sdk 0.3.0__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.
- linxsci/SKILL.md +97 -0
- linxsci/__init__.py +5 -0
- linxsci/agents/openai.yaml +4 -0
- linxsci/references/api.md +105 -0
- linxsci/references/cli.md +46 -0
- linxsci/scripts/__init__.py +1 -0
- linxsci/scripts/cli.py +34 -0
- linxsci/scripts/scholar.py +301 -0
- linxsci/scripts/scholar_client.py +211 -0
- linxsci/scripts/scholar_config.py +111 -0
- linxsci/scripts/scholar_install.py +62 -0
- linxsci/service.json +4 -0
- linxsci_sdk-0.3.0.dist-info/METADATA +218 -0
- linxsci_sdk-0.3.0.dist-info/RECORD +19 -0
- linxsci_sdk-0.3.0.dist-info/WHEEL +5 -0
- linxsci_sdk-0.3.0.dist-info/entry_points.txt +2 -0
- linxsci_sdk-0.3.0.dist-info/top_level.txt +3 -0
- scholar.py +5 -0
- scholar_client.py +2 -0
linxsci/SKILL.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: linxsci-skills
|
|
3
|
+
description: 调用 LinxSci Scholar OpenAPI v2,支持文献检索、论文内容与引用、推荐、学术实体和 PDF 解析。用于 Scholar 研究任务、指定接口调用,以及服务健康和用量查询。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# LinxSci Scholar
|
|
7
|
+
|
|
8
|
+
使用本 Skill 的统一入口 `scripts/cli.py`。所有示例中的 `<skill-dir>` 都替换为本文件所在目录的绝对路径;脚本仅依赖 Python 3.9+ 标准库,无需安装 SDK。
|
|
9
|
+
|
|
10
|
+
## 接口调用入口
|
|
11
|
+
|
|
12
|
+
本 Skill 可调用 [接口文档](references/api.md) 中的全部 34 个接口,覆盖服务信息与健康、各来源搜索和标识解析、论文详情与引用、推荐、作者/机构/期刊及 PDF 解析。全部接口都提供便捷命令,按 [命令速查](references/cli.md) 选择;`request` 保留作通用调试入口。
|
|
13
|
+
|
|
14
|
+
用户指定接口或操作时,按目标路由、参数和响应直接执行;例如查询用量不需要先检索论文,调用推荐接口不需要先走章节阅读流程。按任务需要读取接口文档,使用 `routes` 查看支持的方法与路径(不联网)。
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
python3 <skill-dir>/scripts/cli.py health ready
|
|
18
|
+
python3 <skill-dir>/scripts/cli.py usage
|
|
19
|
+
python3 <skill-dir>/scripts/cli.py search-openalex "large language models"
|
|
20
|
+
python3 <skill-dir>/scripts/cli.py recommend --body @recommendations.json
|
|
21
|
+
python3 <skill-dir>/scripts/cli.py resolve-entities --body @entities.json
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
四个搜索命令都直接接收检索词,可选 `--body` 提供附加 JSON 对象,检索词以位置参数为准。示例中的 JSON 文件须先按部署环境的 Agent API 契约或用户提供的参数准备。便捷命令自动选择方法和路径;JSON 请求使用 `--body @file.json`,PDF 上传接口 `/pdf/parse/upload` 使用 `upload /absolute/path/paper.pdf`,由脚本构造 multipart 的 `file` 字段。接口调用不要求助手额外完成综述或研究分析。
|
|
25
|
+
|
|
26
|
+
## 配置与发现
|
|
27
|
+
|
|
28
|
+
脚本优先读取环境变量 `SCHOLAR_BASE_URL` 和 `SCHOLAR_OPENAPI_KEY`,其次读取 `setup` 保存的用户本地配置。地址默认值是正式服务 `https://linxsci.com`,可显式配置其他部署地址;服务根地址不含 `/openapi/v2`。已有配置优先于发行默认值,升级不会自动切换用户保存的地址。不要读取、展示或让用户把 Key 发到对话。缺少配置时请用户在本机运行向导,Key 通过隐藏输入保存:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python3 <skill-dir>/scripts/cli.py setup
|
|
32
|
+
# 线上地址作为默认提示:
|
|
33
|
+
python3 <skill-dir>/scripts/cli.py setup --online
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
不要把服务端的 `AGENT_PUBLIC_API_KEY` 当成客户端 Key。`config` 仅显示配置状态,`config --clear` 清除保存的配置;执行清除需来自用户请求。
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python3 <skill-dir>/scripts/cli.py doctor
|
|
40
|
+
# 需要元信息正文时再调用:
|
|
41
|
+
python3 <skill-dir>/scripts/cli.py meta
|
|
42
|
+
python3 <skill-dir>/scripts/cli.py routes
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
首次接入可用 `doctor`(调用 `/meta`)验证连接;它也经过用量检查和计费流程,已有有效连接结果时不重复探测。根据 [接口文档](references/api.md) 核对目标路由;对于未定义的业务请求模型,读取部署环境的上游契约或用户提供的请求 JSON。`meta` 不保证返回完整 schema;缺少目标接口必需的业务参数时,说明需要补充的契约或请求 JSON,不猜测字段和分页,也不擅自改为其他接口任务。
|
|
46
|
+
|
|
47
|
+
## 文献工作流
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python3 <skill-dir>/scripts/cli.py search 'graph neural networks'
|
|
51
|
+
python3 <skill-dir>/scripts/cli.py paper 'arxiv:2301.07041'
|
|
52
|
+
python3 <skill-dir>/scripts/cli.py paper 'arxiv:2301.07041' --resource toc
|
|
53
|
+
python3 <skill-dir>/scripts/cli.py paper 'arxiv:2301.07041' --resource sections --item-id '<returned-section-id>'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
从搜索结果中取实际论文 ID;先看详情、目录,再读取相关章节,需要完整文本时才读 `--resource content`。论文 ID 需要 `local:`、`arxiv:`、`openalex:`、`s2:` 或 `doi:` 前缀。便捷命令接收未经编码的 ID(例如 `doi:10.1000/example`),SDK 负责编码。章节和资源 ID 必须取自返回结果。
|
|
57
|
+
|
|
58
|
+
回答研究问题时由助手综合已读取内容,附上实际返回的论文标识、链接和章节依据。区分摘要和全文证据,说明缺失内容;不要声称存在服务端 `ask`、趋势榜或自动综述接口。
|
|
59
|
+
|
|
60
|
+
## 其他接口与 PDF
|
|
61
|
+
|
|
62
|
+
全部接口优先使用 [便捷命令速查](references/cli.md),无需填写 HTTP 方法和路径。各来源检索、推荐、实体和 PDF 创建任务也都有独立命令。
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python3 <skill-dir>/scripts/cli.py resolve --body @resolve.json
|
|
66
|
+
python3 <skill-dir>/scripts/cli.py author '<returned-author-id>' --papers --param '<upstream-key>=<value>'
|
|
67
|
+
python3 <skill-dir>/scripts/cli.py upload /absolute/path/paper.pdf
|
|
68
|
+
python3 <skill-dir>/scripts/cli.py task '<returned-task-id>'
|
|
69
|
+
python3 <skill-dir>/scripts/cli.py task '<returned-task-id>' --result
|
|
70
|
+
python3 <skill-dir>/scripts/cli.py --output /absolute/path/asset.bin paper 'arxiv:2301.07041' --resource assets --item-id '<returned-asset-id>'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
全局可选参数 `--framework-request-id <id>`(放在 `linxsci` 之后、具体动作之前)发送 `X-ANYTHINGRIGHT-REQ-ID`,对应 Scholar 自身错误的 `extra.request-id`;各接口命令的 `--request-id <id>` 发送独立的 `X-Request-ID`。两者用途不同。通用 `request` 的路径相对于 `/openapi/v2`,每个动态 ID 独立 URL 编码(包括 DOI 中的斜杠);查询参数用重复的 `--param key=value`。JSON 通过 `--body @file.json` 原样传入。`upload` 可传 `--field key=value`;上游契约需要 `X-MinerU-API-Key` 时,在本机设置 `SCHOLAR_MINERU_API_KEY`,`parse-pdf`、`upload`、`task`(含 `--result`)及通用 PDF `request` 都会读取它。
|
|
74
|
+
|
|
75
|
+
上传会发送本地文件到配置服务,仅在用户请求解析该文件时执行。任务状态结构未定义,使用实际返回的标识和状态;不自动无限轮询或重复创建任务,确认完成后获取结果。
|
|
76
|
+
|
|
77
|
+
## 输出与故障
|
|
78
|
+
|
|
79
|
+
stdout 输出 JSON 或原始响应;`--output`(放在 `linxsci` 之后、具体动作之前)保留响应原始字节,适用于二进制资源。SDK `Response` 保留 status、headers、body。上游响应原样透传,Scholar 自身错误则使用 `code/data/error/extra` 框架结构;读取 `error.code` 和 `extra.request-id`,不把它当作上游业务模型。`extra.cost` 是请求耗时(秒),不是扣费金额。不要假定列表字段或 PDF 终态。
|
|
80
|
+
|
|
81
|
+
退出码:0 成功,1 HTTP/网络错误,2 参数/本地文件错误。SDK 异常保留原始错误体,并提供 `error_code`、`request_id`、`framework_request_id`、`execution_uncertain` 属性。CLI 默认输出中文诊断和两种请求 ID;`linxsci --json-errors` 输出结构化诊断,不展示服务端错误消息全文。
|
|
82
|
+
|
|
83
|
+
按 HTTP 状态和实际 `error.code` 区分故障:
|
|
84
|
+
|
|
85
|
+
| error.code | 处理 |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `-401` / `-404` | 检查 Scholar Key / 路径和方法 |
|
|
88
|
+
| `-3900` | action 已登记但未启用,联系管理员配置并启用;不要反复重试 |
|
|
89
|
+
| `-3901` | 每日调用限额已满,额度恢复后或管理员处理后再调用 |
|
|
90
|
+
| `-3902` | 服务端 Agent API 地址或 Key 缺失,联系管理员 |
|
|
91
|
+
| `-3903` | 上游连接/传输失败,检查连接;不要将失败等同于业务操作一定未执行 |
|
|
92
|
+
| `-3904` | member begin 未成功,未转发上游,交由管理员排查 |
|
|
93
|
+
| `-3905` | 上游已返回但 finish 未确认,先查询执行与计费结果,再决定是否重试,尤其不要直接重复上传或创建解析任务 |
|
|
94
|
+
|
|
95
|
+
上游业务错误(例如 `validation_error`、`asset_unavailable`)保留其原有格式;不要把全部 502/503 当作相同原因。脚本不自动重试;保留 `Retry-After`,但它不能替代 `-3905` 的执行/计费核查。
|
|
96
|
+
|
|
97
|
+
每次请求预计调用量为 1,Agent API 2xx 对应实际调用量 1,其他状态或连接失败对应 0,预占释放由 member finish 结果确认。不要根据 HTTP 失败自行保证扣费已经释放,尤其遇到 `-3905`。避免无目的的重复检索、诊断与轮询;查询动作也走调用限额。详细规则见 [references/api.md](references/api.md)。
|
linxsci/__init__.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Scholar OpenAPI v2 使用文档
|
|
2
|
+
|
|
3
|
+
Scholar 的 `/openapi/v2` 将请求转发给独立 Agent API。客户端只需使用 Scholar API Key;服务端负责向 Agent API 提供自己的 Key。接口路径、业务请求参数和上游业务响应以 Agent API 的 `/openapi/v1` 契约为准。健康检查分别转发到 Agent API 的 `/health/live` 和 `/health/ready`。
|
|
4
|
+
|
|
5
|
+
可直接导入 [Postman 集合](./Scholar-OpenAPI-v2.postman_collection.json) 调用下表中的接口。
|
|
6
|
+
|
|
7
|
+
## 认证和配置
|
|
8
|
+
|
|
9
|
+
| 请求头 | 提供方 | 用途 |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `Authorization: Bearer <Scholar OpenAPI Key>` | 客户端 | Scholar 校验 API Key,所有 v2 接口都需要。 |
|
|
12
|
+
| `X-Request-ID` | 客户端,可选 | 转发给 Agent API;Scholar 自身返回错误时也会在响应头回传。 |
|
|
13
|
+
| `X-ANYTHINGRIGHT-REQ-ID` | 客户端,可选 | 指定 Scholar 框架响应中的 `extra.request-id`;未提供时由框架生成。 |
|
|
14
|
+
| `X-MinerU-API-Key` | 客户端,按需 | PDF 解析相关请求转发给 Agent API。 |
|
|
15
|
+
|
|
16
|
+
Scholar 服务端配置 `AgentPublicServerIp` 和 `AgentPublicAPIKey`;相应环境变量 `AGENT_PUBLIC_API_URL`、`AGENT_PUBLIC_API_KEY` 可在配置项为空时使用。转发到 Agent API `/openapi/v1` 的请求会由服务端附加 `X-API-Key`,客户端无需提供第二把 Key。v2 的这组配置与旧版 Agent API 配置独立。
|
|
17
|
+
|
|
18
|
+
## 接口目录
|
|
19
|
+
|
|
20
|
+
以下为 Scholar 对外路径。除健康检查外,上游使用相同的 HTTP 方法及 `/openapi/v1` 路径前缀。
|
|
21
|
+
|
|
22
|
+
| Postman 分类 | 方法 | Scholar 路径 | 接口 |
|
|
23
|
+
| --- | --- | --- | --- |
|
|
24
|
+
| 服务信息与健康检查 | GET | `/openapi/v2/meta` | 接口元信息 |
|
|
25
|
+
| 服务信息与健康检查 | GET | `/openapi/v2/usage` | 用量信息 |
|
|
26
|
+
| 服务信息与健康检查 | GET | `/openapi/v2/health/live` | 存活检查 |
|
|
27
|
+
| 服务信息与健康检查 | GET | `/openapi/v2/health/ready` | 就绪检查 |
|
|
28
|
+
| 论文搜索与解析 | POST | `/openapi/v2/papers/search` | 综合论文搜索 |
|
|
29
|
+
| 论文搜索与解析 | POST | `/openapi/v2/search/openalex` | OpenAlex 搜索 |
|
|
30
|
+
| 论文搜索与解析 | POST | `/openapi/v2/search/semantic-scholar` | Semantic Scholar 搜索 |
|
|
31
|
+
| 论文搜索与解析 | POST | `/openapi/v2/search/google-scholar` | Google Scholar 搜索 |
|
|
32
|
+
| 论文搜索与解析 | POST | `/openapi/v2/papers/resolve` | 论文标识解析 |
|
|
33
|
+
| 论文搜索与解析 | POST | `/openapi/v2/papers:batchGet` | 批量获取论文 |
|
|
34
|
+
| 论文详情与引用 | GET | `/openapi/v2/papers/{paper_id}` | 论文详情 |
|
|
35
|
+
| 论文详情与引用 | GET | `/openapi/v2/papers/{paper_id}/content` | 论文内容 |
|
|
36
|
+
| 论文详情与引用 | GET | `/openapi/v2/papers/{paper_id}/toc` | 论文目录 |
|
|
37
|
+
| 论文详情与引用 | GET | `/openapi/v2/papers/{paper_id}/sections/{section_id}` | 论文章节 |
|
|
38
|
+
| 论文详情与引用 | GET | `/openapi/v2/papers/{paper_id}/figures` | 论文图表 |
|
|
39
|
+
| 论文详情与引用 | GET | `/openapi/v2/papers/{paper_id}/assets/{asset_id}` | 论文资源 |
|
|
40
|
+
| 论文详情与引用 | GET | `/openapi/v2/papers/{paper_id}/references` | 参考文献 |
|
|
41
|
+
| 论文详情与引用 | GET | `/openapi/v2/papers/{paper_id}/citations` | 被引论文 |
|
|
42
|
+
| 论文详情与引用 | GET | `/openapi/v2/papers/{paper_id}/related` | 相关论文 |
|
|
43
|
+
| 论文详情与引用 | GET | `/openapi/v2/papers/{paper_id}/graph` | 引用图谱 |
|
|
44
|
+
| 论文详情与引用 | GET | `/openapi/v2/papers/{paper_id}/citation` | 论文引用格式 |
|
|
45
|
+
| 论文推荐 | POST | `/openapi/v2/recommendations` | 论文推荐 |
|
|
46
|
+
| 论文推荐 | GET | `/openapi/v2/recommendations/mobile-cards` | 移动端推荐卡片 |
|
|
47
|
+
| 作者、机构与期刊 | GET | `/openapi/v2/authors/{author_id}` | 作者详情 |
|
|
48
|
+
| 作者、机构与期刊 | GET | `/openapi/v2/authors/{author_id}/papers` | 作者论文 |
|
|
49
|
+
| 作者、机构与期刊 | GET | `/openapi/v2/institutions/{institution_id}` | 机构详情 |
|
|
50
|
+
| 作者、机构与期刊 | GET | `/openapi/v2/institutions/{institution_id}/papers` | 机构论文 |
|
|
51
|
+
| 作者、机构与期刊 | GET | `/openapi/v2/venues/{venue_id}` | 期刊或会议详情 |
|
|
52
|
+
| 作者、机构与期刊 | GET | `/openapi/v2/venues/{venue_id}/papers` | 期刊或会议论文 |
|
|
53
|
+
| 作者、机构与期刊 | POST | `/openapi/v2/entities:resolve` | 实体标识解析 |
|
|
54
|
+
| PDF 解析 | POST | `/openapi/v2/pdf/parse` | 创建 PDF 解析任务 |
|
|
55
|
+
| PDF 解析 | POST | `/openapi/v2/pdf/parse/upload` | 上传 PDF 并创建解析任务 |
|
|
56
|
+
| PDF 解析 | GET | `/openapi/v2/pdf/parse/{task_id}` | 查询 PDF 解析任务 |
|
|
57
|
+
| PDF 解析 | GET | `/openapi/v2/pdf/parse/{task_id}/result` | 获取 PDF 解析结果 |
|
|
58
|
+
|
|
59
|
+
`paper_id` 支持 `local:`、`arxiv:`、`openalex:`、`s2:`、`doi:` 前缀。ID 中的 `/` 需要 URL 编码,例如 `doi:10.1000%2Fexample`。`POST` 接口的 JSON 字段按 Agent API 契约填写;Postman 中的 `{}` 只是待编辑模板。PDF 上传使用 `multipart/form-data` 的 `file` 字段,具体参数与文件大小限制以 Agent API 配置为准。
|
|
60
|
+
|
|
61
|
+
## API Key 调用与计费流程
|
|
62
|
+
|
|
63
|
+
正常调用依次经过 Scholar 的 API Key 校验、member 的 `/internal/openapi/calls/begin`、Agent API 转发和 member 的 `/internal/openapi/calls/finish`。若服务端 Agent API 配置缺失,请求会在 `begin` 前返回;若 `begin` 拒绝请求,则不会转发上游或调用 `finish`。路由映射生成固定的 `openapi_v2_*` action,例如论文资源接口的 action 为 `openapi_v2_paper_asset`。
|
|
64
|
+
|
|
65
|
+
首次调用尚未登记的 action 时,member 会在 `openapi_register` 创建**待启用**记录;该次请求返回 HTTP 503,且不会转发给 Agent API。管理员按免费或计费策略配置并启用对应 action 后,才能继续调用。
|
|
66
|
+
|
|
67
|
+
每次请求预计调用量为 1。Agent API 返回 2xx 时,实际调用量为 1;返回其他状态或发生连接失败时,实际调用量为 0,member 根据 `finish` 结果释放预占积分。每日调用限额由 member 检查。
|
|
68
|
+
|
|
69
|
+
## 响应与错误
|
|
70
|
+
|
|
71
|
+
Agent API 已返回且 `finish` 成功时,Scholar 保留上游 HTTP 状态码和响应体,并透传 `Content-Type`、`X-Request-ID`、`Retry-After`、`Cache-Control`。这也适用于 Agent API 的业务错误,例如 `validation_error` 或 `asset_unavailable`;其错误体仍使用 Agent API 的格式。
|
|
72
|
+
|
|
73
|
+
Scholar 自身产生的鉴权、配置、用量和连接错误使用统一框架结构:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"code": -1,
|
|
78
|
+
"data": {},
|
|
79
|
+
"error": {
|
|
80
|
+
"code": -3900,
|
|
81
|
+
"msg": "OpenAPI action openapi_v2_paper_asset has been registered but is not enabled. Ask an administrator to configure and enable it before retrying."
|
|
82
|
+
},
|
|
83
|
+
"extra": {
|
|
84
|
+
"request-id": "example-request-id",
|
|
85
|
+
"cost": 0.01
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
示例中的 HTTP 状态为 503;`extra.cost` 是本次请求耗时,单位为秒。这里的 `extra.request-id` 来自 `X-ANYTHINGRIGHT-REQ-ID` 或由框架生成。member 内部使用的 `-3501`、`-3500` 会分别转换为下表的对外错误码。
|
|
91
|
+
|
|
92
|
+
| HTTP | `error.code` | 原因与处理 |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| 401 | `-401` | Scholar API Key 缺失、无效或无法识别用户;检查 `Authorization`。 |
|
|
95
|
+
| 404 | `-404` | 未匹配到 v2 接口映射;检查路径和 HTTP 方法。 |
|
|
96
|
+
| 503 | `-3900` | 接口 action 已登记但未启用;联系管理员配置、启用后重试。 |
|
|
97
|
+
| 429 | `-3901` | 已达到每日调用限额;额度恢复后重试或联系管理员。 |
|
|
98
|
+
| 503 | `-3902` | Agent API 地址或服务端 Key 未配置;联系服务端管理员。 |
|
|
99
|
+
| 502 | `-3903` | Agent API 连接或传输失败;Scholar 会尝试以失败结果调用 `finish`。 |
|
|
100
|
+
| 502 | `-3904` | member 的 `begin` 未成功,尚未转发上游;检查 member 服务和内部鉴权配置。 |
|
|
101
|
+
| 502 | `-3905` | Agent API 已返回,但 member 的 `finish` 未确认;先查询执行与计费结果,再决定是否重试可能产生写入的请求。 |
|
|
102
|
+
|
|
103
|
+
## Postman 调用
|
|
104
|
+
|
|
105
|
+
导入 [Scholar OpenAPI v2 集合](./Scholar-OpenAPI-v2.postman_collection.json),设置 `scholar_base_url` 和 `scholar_api_key`。GET 请求可直接按路径变量调用;POST 请求先按 Agent API 契约填写 JSON。调用 `/openapi/v2/pdf/parse/upload` 时,在 Postman 的 `file` 字段中选择本地 PDF。
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# 全接口便捷命令
|
|
2
|
+
|
|
3
|
+
全部 34 个接口都可直接调用,无需指定 HTTP 方法或接口路径。已安装 CLI 时使用下表的 `linxsci`;仅安装 Skill 时,替换为 `python3 <skill-dir>/scripts/cli.py`。
|
|
4
|
+
|
|
5
|
+
动态 ID 使用响应返回的原始值,由 CLI 自动编码。表中的尖括号是占位参数,须替换。JSON 文件需按实际 Agent API 契约准备;四个搜索命令把检索词发送为 `query`,可选 `--body` 只提供附加参数,不能覆盖位置参数中的检索词;不推断其他业务字段。`{}` 不是通用有效业务请求。
|
|
6
|
+
|
|
7
|
+
| 编号 | 接口 | 命令 |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| 01 | 接口元信息 | `linxsci meta` |
|
|
10
|
+
| 02 | 用量信息 | `linxsci usage` |
|
|
11
|
+
| 03 | 存活检查 | `linxsci health live` |
|
|
12
|
+
| 04 | 就绪检查 | `linxsci health ready` |
|
|
13
|
+
| 05 | 综合论文搜索 | `linxsci search "large language models"` |
|
|
14
|
+
| 06 | OpenAlex 搜索 | `linxsci search-openalex "large language models"` |
|
|
15
|
+
| 07 | Semantic Scholar 搜索 | `linxsci search-semantic-scholar "large language models"` |
|
|
16
|
+
| 08 | Google Scholar 搜索 | `linxsci search-google-scholar "large language models"` |
|
|
17
|
+
| 09 | 论文标识解析 | `linxsci resolve --body @papers-resolve.json` |
|
|
18
|
+
| 10 | 批量获取论文 | `linxsci batch-get --body @papers-batch-get.json` |
|
|
19
|
+
| 11 | 论文详情 | `linxsci paper "<paper-id>"` |
|
|
20
|
+
| 12 | 论文内容 | `linxsci paper "<paper-id>" --resource content` |
|
|
21
|
+
| 13 | 论文目录 | `linxsci paper "<paper-id>" --resource toc` |
|
|
22
|
+
| 14 | 论文章节 | `linxsci paper "<paper-id>" --resource sections --item-id "<section-id>"` |
|
|
23
|
+
| 15 | 论文图表 | `linxsci paper "<paper-id>" --resource figures` |
|
|
24
|
+
| 16 | 论文资源 | `linxsci --output asset.bin paper "<paper-id>" --resource assets --item-id "<asset-id>"` |
|
|
25
|
+
| 17 | 参考文献 | `linxsci paper "<paper-id>" --resource references` |
|
|
26
|
+
| 18 | 被引论文 | `linxsci paper "<paper-id>" --resource citations` |
|
|
27
|
+
| 19 | 相关论文 | `linxsci paper "<paper-id>" --resource related` |
|
|
28
|
+
| 20 | 引用图谱 | `linxsci paper "<paper-id>" --resource graph` |
|
|
29
|
+
| 21 | 论文引用格式 | `linxsci paper "<paper-id>" --resource citation` |
|
|
30
|
+
| 22 | 论文推荐 | `linxsci recommend --body @recommendations.json` |
|
|
31
|
+
| 23 | 移动端推荐卡片 | `linxsci mobile-cards` |
|
|
32
|
+
| 24 | 作者详情 | `linxsci author "<author-id>"` |
|
|
33
|
+
| 25 | 作者论文 | `linxsci author "<author-id>" --papers` |
|
|
34
|
+
| 26 | 机构详情 | `linxsci institution "<institution-id>"` |
|
|
35
|
+
| 27 | 机构论文 | `linxsci institution "<institution-id>" --papers` |
|
|
36
|
+
| 28 | 期刊或会议详情 | `linxsci venue "<venue-id>"` |
|
|
37
|
+
| 29 | 期刊或会议论文 | `linxsci venue "<venue-id>" --papers` |
|
|
38
|
+
| 30 | 实体标识解析 | `linxsci resolve-entities --body @entities-resolve.json` |
|
|
39
|
+
| 31 | 创建 PDF 解析任务 | `linxsci parse-pdf --body @pdf-parse.json` |
|
|
40
|
+
| 32 | 上传 PDF 并创建解析任务 | `linxsci upload "/实际路径/paper.pdf"` |
|
|
41
|
+
| 33 | 查询 PDF 解析任务 | `linxsci task "<task-id>"` |
|
|
42
|
+
| 34 | 获取 PDF 解析结果 | `linxsci task "<task-id>" --result` |
|
|
43
|
+
|
|
44
|
+
所有接口命令支持重复的 `--param key=value` 和 `--request-id`;`--output`、`--timeout`、`--framework-request-id`、`--json-errors` 放在动作之前。PDF 命令按需读取 `SCHOLAR_MINERU_API_KEY`。创建/上传任务后记录真实 ID,不自动重试或无限轮询。
|
|
45
|
+
|
|
46
|
+
`linxsci <动作> --help` 可查看参数。`request` 保留作通用调试入口;该入口的动态 ID 需自行编码。接口定义和错误码见 [api.md](api.md)。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Executable helpers for the LinxSci Scholar Skill."""
|
linxsci/scripts/cli.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""LinxSci 统一命令行入口,常用动作直接运行。"""
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
if __package__:
|
|
6
|
+
from .scholar import VERSION, build_parser, main as scholar_main
|
|
7
|
+
else:
|
|
8
|
+
from scholar import VERSION, build_parser, main as scholar_main
|
|
9
|
+
|
|
10
|
+
# Add a capability only after its command handler is implemented and packaged.
|
|
11
|
+
# Handlers receive their own argv and a display name for argparse help/errors.
|
|
12
|
+
CAPABILITIES = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv=None):
|
|
16
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
17
|
+
if argv and argv[0] in CAPABILITIES:
|
|
18
|
+
# Forward unchanged, including options before the action and negative queries.
|
|
19
|
+
handler = CAPABILITIES[argv[0]][1]
|
|
20
|
+
return handler(argv[1:], prog="linxsci " + argv[0])
|
|
21
|
+
if not argv or argv in (["--help"], ["-h"]):
|
|
22
|
+
parser = build_parser(prog="linxsci")
|
|
23
|
+
extra = [name + ":" + description for name, (description, _) in CAPABILITIES.items()]
|
|
24
|
+
if extra:
|
|
25
|
+
parser.epilog = "扩展能力:" + ";".join(extra)
|
|
26
|
+
if not argv:
|
|
27
|
+
parser.print_help()
|
|
28
|
+
return 0
|
|
29
|
+
parser.parse_args(argv) # argparse prints help and exits successfully.
|
|
30
|
+
return scholar_main(argv, prog="linxsci")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
sys.exit(main())
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""LinxSci Scholar:搜索论文、按章节阅读和解析 PDF;首次使用请运行 linxsci setup。"""
|
|
3
|
+
import argparse
|
|
4
|
+
import getpass
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import warnings
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
if __package__:
|
|
11
|
+
from .scholar_client import ROUTES, ScholarClient, ScholarError, paper_path, segment
|
|
12
|
+
from .scholar_config import config_path, clear_config, load_config, save_config, service_defaults, validate_url
|
|
13
|
+
from .scholar_install import install_skill
|
|
14
|
+
else:
|
|
15
|
+
from scholar_client import ROUTES, ScholarClient, ScholarError, paper_path, segment
|
|
16
|
+
from scholar_config import config_path, clear_config, load_config, save_config, service_defaults, validate_url
|
|
17
|
+
from scholar_install import install_skill
|
|
18
|
+
|
|
19
|
+
VERSION = "0.3.0"
|
|
20
|
+
|
|
21
|
+
SEARCH_COMMANDS = {
|
|
22
|
+
"search": ("/papers/search", "综合论文搜索"),
|
|
23
|
+
"search-openalex": ("/search/openalex", "OpenAlex 搜索"),
|
|
24
|
+
"search-semantic-scholar": ("/search/semantic-scholar", "Semantic Scholar 搜索"),
|
|
25
|
+
"search-google-scholar": ("/search/google-scholar", "Google Scholar 搜索"),
|
|
26
|
+
}
|
|
27
|
+
JSON_COMMANDS = {
|
|
28
|
+
"resolve": ("/papers/resolve", "解析论文标识"),
|
|
29
|
+
"batch-get": ("/papers:batchGet", "批量获取论文"),
|
|
30
|
+
"recommend": ("/recommendations", "推荐论文"),
|
|
31
|
+
"resolve-entities": ("/entities:resolve", "解析作者、机构或期刊标识"),
|
|
32
|
+
"parse-pdf": ("/pdf/parse", "按 JSON 请求创建 PDF 解析任务"),
|
|
33
|
+
}
|
|
34
|
+
ENTITY_COMMANDS = {"author": "authors", "institution": "institutions", "venue": "venues"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def error_info(error):
|
|
38
|
+
code = error.error_code
|
|
39
|
+
hints = {
|
|
40
|
+
401: "API Key 无效或已过期,请运行 linxsci setup 重新配置。",
|
|
41
|
+
403: "账号无权调用此接口,请检查账号权限。",
|
|
42
|
+
404: "服务未提供此接口,请核对服务根地址和部署版本。",
|
|
43
|
+
429: "请求过于频繁或额度不足,请稍后重试或检查账号用量。",
|
|
44
|
+
502: "Scholar 无法连接上游服务,请联系服务维护者。",
|
|
45
|
+
503: "服务暂不可用,请稍后重试或联系服务维护者。",
|
|
46
|
+
}
|
|
47
|
+
message = hints.get(error.status, "服务调用失败,请检查服务地址、网络和服务状态。")
|
|
48
|
+
code_hints = {
|
|
49
|
+
-401: hints[401],
|
|
50
|
+
-404: hints[404],
|
|
51
|
+
-3900: "接口 action 已登记但未启用,请联系管理员配置并启用后再调用。",
|
|
52
|
+
-3901: "已达到每日调用限额,请在额度恢复后重试或联系管理员。",
|
|
53
|
+
-3902: "Agent API 地址或服务端 Key 未配置,请联系服务端管理员。",
|
|
54
|
+
-3903: "Agent API 连接或传输失败,请联系服务维护者检查连接。",
|
|
55
|
+
-3904: "member 的 begin 未成功,请求尚未转发上游;请联系管理员检查 member 服务和内部鉴权。",
|
|
56
|
+
-3905: "Agent API 已返回,但 member 的 finish 未确认。请先查询执行与计费结果,再决定是否重试;不要直接重复上传或创建任务。",
|
|
57
|
+
# Keep compatibility with older Scholar deployments.
|
|
58
|
+
"upstream_unconfigured": "Scholar 服务端上游配置不完整,请联系服务维护者。",
|
|
59
|
+
}
|
|
60
|
+
message = code_hints.get(code, message)
|
|
61
|
+
headers = {k.lower(): v for k, v in error.headers.items()}
|
|
62
|
+
# Only selected response metadata is exposed; never print the upstream error body.
|
|
63
|
+
return {"message": message, "status": error.status, "error_code": code,
|
|
64
|
+
"request_id": error.request_id, "framework_request_id": error.framework_request_id,
|
|
65
|
+
"execution_uncertain": error.execution_uncertain, "retry_after": headers.get("retry-after")}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def print_error_details(report, stream):
|
|
69
|
+
if report.get("error_code") is not None:
|
|
70
|
+
print("错误码:" + str(report["error_code"]), file=stream)
|
|
71
|
+
if report.get("request_id"):
|
|
72
|
+
print("上游/代理请求 ID:" + report["request_id"], file=stream)
|
|
73
|
+
if report.get("framework_request_id"):
|
|
74
|
+
print("Scholar 框架请求 ID:" + report["framework_request_id"], file=stream)
|
|
75
|
+
if report.get("retry_after"):
|
|
76
|
+
print("Retry-After:" + report["retry_after"], file=stream)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def doctor(client, as_json=False):
|
|
80
|
+
report = {"ok": False, "base_url": client.base_url, "key_configured": True,
|
|
81
|
+
"config_file": str(config_path())}
|
|
82
|
+
try:
|
|
83
|
+
response = client.request("GET", "/meta")
|
|
84
|
+
response.json() # A 200 HTML login page is not a working API connection.
|
|
85
|
+
report.update(ok=True, status=response.status, message="连接成功,可以开始检索论文。")
|
|
86
|
+
except ScholarError as error:
|
|
87
|
+
report.update(error_info(error))
|
|
88
|
+
except (ValueError, UnicodeDecodeError):
|
|
89
|
+
report["message"] = "服务返回了非 JSON 内容,请核对根地址,排除登录页或代理页面。"
|
|
90
|
+
if as_json:
|
|
91
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
92
|
+
else:
|
|
93
|
+
print("服务地址:" + report["base_url"])
|
|
94
|
+
print(report["message"])
|
|
95
|
+
print_error_details(report, sys.stdout)
|
|
96
|
+
return 0 if report["ok"] else 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def setup(args):
|
|
100
|
+
defaults = service_defaults()
|
|
101
|
+
try:
|
|
102
|
+
saved = load_config()
|
|
103
|
+
except ValueError:
|
|
104
|
+
saved = {} # setup can repair a corrupt configuration.
|
|
105
|
+
base_url = args.base_url
|
|
106
|
+
if not base_url:
|
|
107
|
+
base_url = defaults["online_base_url"] if args.online else saved.get("base_url", defaults["base_url"])
|
|
108
|
+
if not args.api_key_stdin:
|
|
109
|
+
if not sys.stdin.isatty():
|
|
110
|
+
raise ValueError("配置向导需要交互终端;自动配置请使用 --api-key-stdin。")
|
|
111
|
+
print("LinxSci Scholar 配置向导(API Key 输入不会显示)")
|
|
112
|
+
print("线上服务:" + defaults["online_base_url"])
|
|
113
|
+
base_url = input("服务根地址 [" + base_url + "]:").strip() or base_url
|
|
114
|
+
try:
|
|
115
|
+
with warnings.catch_warnings():
|
|
116
|
+
warnings.simplefilter("error", getpass.GetPassWarning)
|
|
117
|
+
key = getpass.getpass("请输入 API Key" + ("(留空保留已有 Key)" if saved.get("api_key") else "") + ":")
|
|
118
|
+
except getpass.GetPassWarning:
|
|
119
|
+
raise ValueError("当前终端无法隐藏 Key 输入,请在普通终端运行 setup。") from None
|
|
120
|
+
key = key or saved.get("api_key", "")
|
|
121
|
+
else:
|
|
122
|
+
key = sys.stdin.readline().rstrip("\r\n")
|
|
123
|
+
base_url = validate_url(base_url)
|
|
124
|
+
path = save_config(base_url, key)
|
|
125
|
+
print("配置已保存:" + str(path))
|
|
126
|
+
if os.environ.get("SCHOLAR_BASE_URL") or os.environ.get("SCHOLAR_OPENAPI_KEY"):
|
|
127
|
+
print("提示:后续调用时,已有环境变量优先于本地配置。")
|
|
128
|
+
if args.no_check:
|
|
129
|
+
print("已跳过连接检查。稍后运行 linxsci doctor 检查连接。")
|
|
130
|
+
return 0
|
|
131
|
+
return doctor(ScholarClient(base_url, key, timeout=args.timeout, framework_request_id=args.framework_request_id))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def pairs(values):
|
|
135
|
+
result = {}
|
|
136
|
+
for value in values or []:
|
|
137
|
+
key, sep, val = value.partition("=")
|
|
138
|
+
if not sep or not key:
|
|
139
|
+
raise ValueError("Parameters must use key=value")
|
|
140
|
+
result.setdefault(key, []).append(val)
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_parser(*, prog=None):
|
|
145
|
+
parser = argparse.ArgumentParser(prog=prog, description=__doc__)
|
|
146
|
+
parser.add_argument("--version", action="version", version=VERSION)
|
|
147
|
+
parser.add_argument("--json-errors", action="store_true", help="Output structured errors on stderr")
|
|
148
|
+
parser.add_argument("--timeout", type=float, default=60)
|
|
149
|
+
parser.add_argument("--framework-request-id", help="设置 X-ANYTHINGRIGHT-REQ-ID,用于定位 Scholar 框架请求")
|
|
150
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
151
|
+
sub.add_parser("routes", help="列出已知接口,无需配置 Key")
|
|
152
|
+
configure = sub.add_parser("setup", help="首次配置服务地址和 API Key")
|
|
153
|
+
configure.add_argument("--base-url", help="Service root URL")
|
|
154
|
+
configure.add_argument("--online", action="store_true", help="Use linxsci.com as the suggested service")
|
|
155
|
+
configure.add_argument("--api-key-stdin", action="store_true", help="Read the Key from stdin without echoing it")
|
|
156
|
+
configure.add_argument("--no-check", action="store_true", help="Save settings without a network check")
|
|
157
|
+
diagnose = sub.add_parser("doctor", help="检查配置和服务连接")
|
|
158
|
+
diagnose.add_argument("--json", action="store_true")
|
|
159
|
+
configuration = sub.add_parser("config", help="查看配置状态(不显示 Key)或清除配置")
|
|
160
|
+
configuration.add_argument("--clear", action="store_true")
|
|
161
|
+
installer = sub.add_parser("install-skill", help="安装自包含 Skill,无需联网")
|
|
162
|
+
installer.add_argument("--skills-dir", help="Parent directory for the Skill (default: ~/.agents/skills)")
|
|
163
|
+
installer.add_argument("--update", action="store_true", help="Update a managed installation and retain a backup")
|
|
164
|
+
def api_command(name, help):
|
|
165
|
+
command = sub.add_parser(name, help=help, description=help)
|
|
166
|
+
command.add_argument("--param", action="append", help="查询参数 key=value,可重复;字段按上游契约")
|
|
167
|
+
command.add_argument("--request-id", help="上游/代理 X-Request-ID")
|
|
168
|
+
return command
|
|
169
|
+
|
|
170
|
+
for name, help in (("meta", "接口元信息"), ("usage", "用量信息"),
|
|
171
|
+
("mobile-cards", "移动端推荐卡片")):
|
|
172
|
+
api_command(name, help)
|
|
173
|
+
health = api_command("health", "存活或就绪检查")
|
|
174
|
+
health.add_argument("check", choices=("live", "ready"))
|
|
175
|
+
for name, (_, help) in JSON_COMMANDS.items():
|
|
176
|
+
command = api_command(name, help)
|
|
177
|
+
command.add_argument("--body", required=True, help="JSON 或 @文件.json,字段按上游契约")
|
|
178
|
+
for name in ENTITY_COMMANDS:
|
|
179
|
+
entity = api_command(name, {"author": "作者详情或论文", "institution": "机构详情或论文", "venue": "期刊/会议详情或论文"}[name])
|
|
180
|
+
entity.add_argument("entity_id", help="响应中的原始 ID,无需 URL 编码")
|
|
181
|
+
entity.add_argument("--papers", action="store_true", help="获取该实体的论文")
|
|
182
|
+
for name, (_, help) in SEARCH_COMMANDS.items():
|
|
183
|
+
search = api_command(name, help)
|
|
184
|
+
search.add_argument("query", help="检索词")
|
|
185
|
+
search.add_argument("--body", help="可选附加参数,JSON 对象或 @文件.json;字段按上游契约")
|
|
186
|
+
paper = api_command("paper", "读取论文详情、内容、目录、章节或引用等资源")
|
|
187
|
+
paper.add_argument("paper_id")
|
|
188
|
+
paper.add_argument("--resource", choices=("content", "toc", "sections", "figures", "assets", "references", "citations", "related", "graph", "citation"))
|
|
189
|
+
paper.add_argument("--item-id")
|
|
190
|
+
upload = api_command("upload", "上传 PDF 并创建解析任务")
|
|
191
|
+
upload.add_argument("file")
|
|
192
|
+
upload.add_argument("--field", action="append")
|
|
193
|
+
task = api_command("task", "查询 PDF 任务或结果")
|
|
194
|
+
task.add_argument("task_id")
|
|
195
|
+
task.add_argument("--result", action="store_true")
|
|
196
|
+
request = api_command("request", "通用接口调试入口;日常调用使用便捷命令")
|
|
197
|
+
request.add_argument("method", choices=("GET", "POST"))
|
|
198
|
+
request.add_argument("path", help="Encoded path relative to /openapi/v2")
|
|
199
|
+
request.add_argument("--body", help="JSON or @file; required for JSON POST")
|
|
200
|
+
parser.add_argument("--output", help="Write response bytes to a file (also supports binary assets)")
|
|
201
|
+
return parser
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main(argv=None, *, prog=None):
|
|
205
|
+
parser = build_parser(prog=prog)
|
|
206
|
+
args = parser.parse_args(argv)
|
|
207
|
+
try:
|
|
208
|
+
if args.command == "setup":
|
|
209
|
+
return setup(args)
|
|
210
|
+
if args.command == "config":
|
|
211
|
+
if args.clear:
|
|
212
|
+
clear_config()
|
|
213
|
+
print("本地配置已清除;如设置了环境变量,请另行清除。")
|
|
214
|
+
else:
|
|
215
|
+
saved = load_config()
|
|
216
|
+
print(json.dumps({"config_file": str(config_path()),
|
|
217
|
+
"saved_base_url": saved.get("base_url"),
|
|
218
|
+
"saved_key_configured": bool(saved.get("api_key")),
|
|
219
|
+
"environment_url_override": bool(os.environ.get("SCHOLAR_BASE_URL")),
|
|
220
|
+
"environment_key_override": bool(os.environ.get("SCHOLAR_OPENAPI_KEY"))},
|
|
221
|
+
ensure_ascii=False, indent=2))
|
|
222
|
+
return 0
|
|
223
|
+
if args.command == "install-skill":
|
|
224
|
+
destination, backup = install_skill(args.skills_dir, args.update)
|
|
225
|
+
print("Skill 已安装:" + str(destination))
|
|
226
|
+
if backup:
|
|
227
|
+
print("旧版本备份:" + str(backup))
|
|
228
|
+
print("现在可在助手中使用 $linxsci-skills;如果没有显示,请重启助手。")
|
|
229
|
+
return 0
|
|
230
|
+
if args.command == "routes":
|
|
231
|
+
print(json.dumps(ROUTES, ensure_ascii=False, indent=2))
|
|
232
|
+
return 0
|
|
233
|
+
client = ScholarClient(timeout=args.timeout, framework_request_id=args.framework_request_id)
|
|
234
|
+
if args.command == "doctor":
|
|
235
|
+
return doctor(client, args.json)
|
|
236
|
+
def body(value):
|
|
237
|
+
return json.loads(Path(value[1:]).read_text(encoding="utf-8") if value.startswith("@") else value)
|
|
238
|
+
def invoke(method, path, **options):
|
|
239
|
+
return client.request(method, path, query=pairs(args.param), request_id=args.request_id,
|
|
240
|
+
mineru_key=os.environ.get("SCHOLAR_MINERU_API_KEY") if path.startswith("/pdf/parse") else None,
|
|
241
|
+
**options)
|
|
242
|
+
if args.command in ("meta", "usage"):
|
|
243
|
+
response = invoke("GET", "/" + args.command)
|
|
244
|
+
elif args.command == "mobile-cards":
|
|
245
|
+
response = invoke("GET", "/recommendations/mobile-cards")
|
|
246
|
+
elif args.command in JSON_COMMANDS:
|
|
247
|
+
response = invoke("POST", JSON_COMMANDS[args.command][0], payload=body(args.body))
|
|
248
|
+
elif args.command in ENTITY_COMMANDS:
|
|
249
|
+
path = "/" + ENTITY_COMMANDS[args.command] + "/" + segment(args.entity_id)
|
|
250
|
+
response = invoke("GET", path + ("/papers" if args.papers else ""))
|
|
251
|
+
elif args.command == "health":
|
|
252
|
+
response = invoke("GET", "/health/" + args.check)
|
|
253
|
+
elif args.command in SEARCH_COMMANDS:
|
|
254
|
+
options = body(args.body) if args.body else {}
|
|
255
|
+
if not isinstance(options, dict):
|
|
256
|
+
raise ValueError("Search body must be a JSON object")
|
|
257
|
+
options.pop("query", None)
|
|
258
|
+
response = invoke("POST", SEARCH_COMMANDS[args.command][0], payload={**options, "query": args.query})
|
|
259
|
+
elif args.command == "paper":
|
|
260
|
+
response = invoke("GET", paper_path(args.paper_id, args.resource, args.item_id))
|
|
261
|
+
elif args.command == "upload":
|
|
262
|
+
values = pairs(args.field)
|
|
263
|
+
if any(len(v) != 1 for v in values.values()):
|
|
264
|
+
raise ValueError("Form field names must be unique")
|
|
265
|
+
response = invoke("POST", "/pdf/parse/upload", file=args.file, fields={k: v[0] for k, v in values.items()})
|
|
266
|
+
elif args.command == "task":
|
|
267
|
+
if args.task_id == "upload":
|
|
268
|
+
raise ValueError("task_id cannot be upload")
|
|
269
|
+
response = invoke("GET", "/pdf/parse/" + segment(args.task_id) + ("/result" if args.result else ""))
|
|
270
|
+
else:
|
|
271
|
+
if args.method == "POST" and not args.body:
|
|
272
|
+
raise ValueError("JSON POST requires --body with the upstream request schema; use upload for files")
|
|
273
|
+
response = invoke(args.method, args.path, payload=body(args.body) if args.body else None)
|
|
274
|
+
if args.output:
|
|
275
|
+
Path(args.output).write_bytes(response.body)
|
|
276
|
+
else:
|
|
277
|
+
try:
|
|
278
|
+
print(json.dumps(response.json(), ensure_ascii=False, indent=2))
|
|
279
|
+
except (ValueError, UnicodeDecodeError):
|
|
280
|
+
sys.stdout.buffer.write(response.body)
|
|
281
|
+
return 0
|
|
282
|
+
except ScholarError as error:
|
|
283
|
+
report = error_info(error)
|
|
284
|
+
print(json.dumps(report, ensure_ascii=False) if args.json_errors else report["message"], file=sys.stderr)
|
|
285
|
+
if not args.json_errors:
|
|
286
|
+
print_error_details(report, sys.stderr)
|
|
287
|
+
return 1
|
|
288
|
+
except (ValueError, OSError) as error:
|
|
289
|
+
message = "配置、输入或文件错误:" + str(error)
|
|
290
|
+
if args.command == "doctor" and args.json:
|
|
291
|
+
print(json.dumps({"ok": False, "message": message}, ensure_ascii=False))
|
|
292
|
+
else:
|
|
293
|
+
print(json.dumps({"message": message}, ensure_ascii=False) if args.json_errors else message, file=sys.stderr)
|
|
294
|
+
return 2
|
|
295
|
+
except (EOFError, KeyboardInterrupt):
|
|
296
|
+
print("操作已取消。", file=sys.stderr)
|
|
297
|
+
return 2
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
sys.exit(main())
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Scholar OpenAPI v2 client; Python standard library only."""
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.error import HTTPError, URLError
|
|
10
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
11
|
+
from urllib.request import Request, build_opener, HTTPRedirectHandler
|
|
12
|
+
|
|
13
|
+
if __package__:
|
|
14
|
+
from .scholar_config import resolve_settings
|
|
15
|
+
else:
|
|
16
|
+
from scholar_config import resolve_settings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ScholarError(Exception):
|
|
20
|
+
def __init__(self, message, status=None, body=None, headers=None):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status, self.body, self.headers = status, body, headers or {}
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def error_code(self):
|
|
26
|
+
if isinstance(self.body, dict) and isinstance(self.body.get("error"), dict):
|
|
27
|
+
code = self.body["error"].get("code")
|
|
28
|
+
return code if type(code) in (str, int) else None
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def request_id(self):
|
|
33
|
+
headers = {k.lower(): v for k, v in self.headers.items()}
|
|
34
|
+
if headers.get("x-request-id"):
|
|
35
|
+
return headers["x-request-id"]
|
|
36
|
+
if isinstance(self.body, dict) and isinstance(self.body.get("error"), dict):
|
|
37
|
+
value = self.body["error"].get("request_id")
|
|
38
|
+
return value if isinstance(value, str) else None
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def framework_request_id(self):
|
|
43
|
+
if isinstance(self.body, dict) and isinstance(self.body.get("extra"), dict):
|
|
44
|
+
value = self.body["extra"].get("request-id")
|
|
45
|
+
return value if isinstance(value, str) else None
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def execution_uncertain(self):
|
|
50
|
+
"""The upstream returned, but member did not confirm finish; do not blindly retry."""
|
|
51
|
+
return self.error_code == -3905
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NoRedirect(HTTPRedirectHandler):
|
|
55
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
56
|
+
return None # Never forward credentials to another location.
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Response:
|
|
61
|
+
status: int
|
|
62
|
+
headers: dict
|
|
63
|
+
body: bytes
|
|
64
|
+
|
|
65
|
+
def json(self):
|
|
66
|
+
return json.loads(self.body)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# The proxy document defines routes, but not upstream business schemas.
|
|
70
|
+
ROUTES = [
|
|
71
|
+
("GET", "/meta"), ("GET", "/usage"),
|
|
72
|
+
("GET", "/health/live"), ("GET", "/health/ready"),
|
|
73
|
+
("POST", "/papers/search"), ("POST", "/search/openalex"),
|
|
74
|
+
("POST", "/search/semantic-scholar"), ("POST", "/search/google-scholar"),
|
|
75
|
+
("POST", "/papers/resolve"), ("POST", "/papers:batchGet"),
|
|
76
|
+
("POST", "/recommendations"), ("GET", "/recommendations/mobile-cards"),
|
|
77
|
+
("POST", "/entities:resolve"), ("POST", "/pdf/parse"),
|
|
78
|
+
("POST", "/pdf/parse/upload"), ("GET", "/pdf/parse/{task_id}"),
|
|
79
|
+
("GET", "/pdf/parse/{task_id}/result"),
|
|
80
|
+
]
|
|
81
|
+
ROUTES += [("GET", "/papers/{paper_id}" + suffix) for suffix in
|
|
82
|
+
("", "/content", "/toc", "/sections/{section_id}", "/figures",
|
|
83
|
+
"/assets/{asset_id}", "/references", "/citations", "/related",
|
|
84
|
+
"/graph", "/citation")]
|
|
85
|
+
ROUTES += [("GET", "/" + kind + "/{" + ident + "}" + suffix)
|
|
86
|
+
for kind, ident in (("authors", "author_id"),
|
|
87
|
+
("institutions", "institution_id"), ("venues", "venue_id"))
|
|
88
|
+
for suffix in ("", "/papers")]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def segment(value):
|
|
92
|
+
if not str(value) or str(value) in (".", ".."):
|
|
93
|
+
raise ValueError("Path identifiers must be nonempty and cannot be . or ..")
|
|
94
|
+
return quote(str(value), safe=":")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def paper_segment(value):
|
|
98
|
+
if not re.fullmatch(r"(?:local|arxiv|openalex|s2|doi):.+", value):
|
|
99
|
+
raise ValueError("paper_id requires local:, arxiv:, openalex:, s2: or doi: prefix")
|
|
100
|
+
return segment(value)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def paper_path(paper_id, resource=None, item_id=None):
|
|
104
|
+
path = "/papers/" + paper_segment(paper_id)
|
|
105
|
+
if resource:
|
|
106
|
+
if resource not in ("content", "toc", "sections", "figures", "assets", "references", "citations", "related", "graph", "citation"):
|
|
107
|
+
raise ValueError("Unknown paper resource")
|
|
108
|
+
path += "/" + resource
|
|
109
|
+
if resource in ("sections", "assets"):
|
|
110
|
+
path += "/" + segment(item_id if item_id is not None else "")
|
|
111
|
+
elif item_id is not None:
|
|
112
|
+
raise ValueError("item_id is only supported for sections or assets")
|
|
113
|
+
elif item_id is not None:
|
|
114
|
+
raise ValueError("item_id requires sections or assets")
|
|
115
|
+
return path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def request_header(value, name):
|
|
119
|
+
if not isinstance(value, str) or not value or any(ord(c) < 32 or ord(c) > 126 for c in value):
|
|
120
|
+
raise ValueError(name + " must be a nonempty printable ASCII value")
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ScholarClient:
|
|
125
|
+
def __init__(self, base_url=None, api_key=None, timeout=60, *, framework_request_id=None):
|
|
126
|
+
base_url, api_key = resolve_settings(base_url, api_key)
|
|
127
|
+
url = urlsplit(base_url)
|
|
128
|
+
if url.scheme not in ("http", "https") or not url.netloc or url.username or url.password or url.query or url.fragment:
|
|
129
|
+
raise ValueError("SCHOLAR_BASE_URL must be an http(s) service root URL")
|
|
130
|
+
if url.path.endswith(("/openapi/v2", "/openapi/v1")):
|
|
131
|
+
raise ValueError("Base URL must not include /openapi/v2 or /openapi/v1")
|
|
132
|
+
if not api_key or any(c in api_key for c in "\r\n"):
|
|
133
|
+
raise ValueError("Set SCHOLAR_OPENAPI_KEY to a valid Scholar key")
|
|
134
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
135
|
+
raise ValueError("timeout must be a positive finite number")
|
|
136
|
+
self.base_url, self.api_key, self.timeout = base_url, api_key, timeout
|
|
137
|
+
self.framework_request_id = (request_header(framework_request_id, "X-ANYTHINGRIGHT-REQ-ID")
|
|
138
|
+
if framework_request_id is not None else None)
|
|
139
|
+
self.opener = build_opener(NoRedirect())
|
|
140
|
+
|
|
141
|
+
def request(self, method, path, *, query=None, payload=None, file=None,
|
|
142
|
+
fields=None, mineru_key=None, request_id=None, framework_request_id=None):
|
|
143
|
+
method = method.upper()
|
|
144
|
+
if not any(method == m and re.fullmatch(re.sub(r"\{[^}]+\}", r"[^/]+", p), path)
|
|
145
|
+
for m, p in ROUTES):
|
|
146
|
+
raise ValueError("Unsupported v2 route or HTTP method")
|
|
147
|
+
if any(x in path for x in ("?", "#")) or any(x in (".", "..") for x in path.split("/")):
|
|
148
|
+
raise ValueError("Invalid path; pass query separately and encode identifiers")
|
|
149
|
+
if method == "GET" and (payload is not None or file is not None):
|
|
150
|
+
raise ValueError("GET requests cannot carry a body")
|
|
151
|
+
if path == "/pdf/parse/upload" and file is None:
|
|
152
|
+
raise ValueError("PDF upload requires a file")
|
|
153
|
+
if file is not None and (path != "/pdf/parse/upload" or payload is not None):
|
|
154
|
+
raise ValueError("file is only supported for PDF upload without JSON")
|
|
155
|
+
if fields and file is None:
|
|
156
|
+
raise ValueError("Form fields require PDF upload")
|
|
157
|
+
if mineru_key and not path.startswith("/pdf/parse"):
|
|
158
|
+
raise ValueError("MinerU key is only supported on PDF routes")
|
|
159
|
+
headers = {"Authorization": "Bearer " + self.api_key,
|
|
160
|
+
"X-Request-ID": request_header(request_id, "X-Request-ID") if request_id is not None else str(uuid.uuid4())}
|
|
161
|
+
framework_id = framework_request_id if framework_request_id is not None else self.framework_request_id
|
|
162
|
+
if framework_id is not None:
|
|
163
|
+
headers["X-ANYTHINGRIGHT-REQ-ID"] = request_header(framework_id, "X-ANYTHINGRIGHT-REQ-ID")
|
|
164
|
+
if mineru_key:
|
|
165
|
+
headers["X-MinerU-API-Key"] = mineru_key
|
|
166
|
+
body = None
|
|
167
|
+
if payload is not None:
|
|
168
|
+
body = json.dumps(payload, ensure_ascii=False).encode()
|
|
169
|
+
headers["Content-Type"] = "application/json"
|
|
170
|
+
if file is not None:
|
|
171
|
+
source = Path(file)
|
|
172
|
+
boundary = "scholar-" + uuid.uuid4().hex
|
|
173
|
+
chunks = []
|
|
174
|
+
for key, value in (fields or {}).items():
|
|
175
|
+
if any(c in key for c in '\r\n"'):
|
|
176
|
+
raise ValueError("Invalid form field name")
|
|
177
|
+
chunks.append((f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n{value}\r\n').encode())
|
|
178
|
+
filename = source.name.replace('"', '_').replace("\r", "_").replace("\n", "_")
|
|
179
|
+
chunks.append((f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\nContent-Type: application/pdf\r\n\r\n').encode())
|
|
180
|
+
chunks += [source.read_bytes(), f"\r\n--{boundary}--\r\n".encode()]
|
|
181
|
+
body = b"".join(chunks)
|
|
182
|
+
headers["Content-Type"] = "multipart/form-data; boundary=" + boundary
|
|
183
|
+
url = self.base_url + "/openapi/v2" + path
|
|
184
|
+
if query:
|
|
185
|
+
url += "?" + urlencode(query, doseq=True)
|
|
186
|
+
try:
|
|
187
|
+
with self.opener.open(Request(url, data=body, headers=headers, method=method), timeout=self.timeout) as response:
|
|
188
|
+
return Response(response.status, dict(response.headers), response.read())
|
|
189
|
+
except HTTPError as error:
|
|
190
|
+
raw = error.read()
|
|
191
|
+
try:
|
|
192
|
+
detail = json.loads(raw)
|
|
193
|
+
except (ValueError, UnicodeDecodeError):
|
|
194
|
+
detail = raw.decode("utf-8", errors="replace")
|
|
195
|
+
raise ScholarError(f"Scholar HTTP {error.code}", error.code, detail, dict(error.headers)) from None
|
|
196
|
+
except (URLError, TimeoutError, OSError):
|
|
197
|
+
raise ScholarError("Scholar network request failed; check service URL and connectivity") from None
|
|
198
|
+
|
|
199
|
+
def search(self, query, **options):
|
|
200
|
+
return self.request("POST", "/papers/search", payload={**options, "query": query})
|
|
201
|
+
|
|
202
|
+
def paper(self, paper_id, resource=None, *, item_id=None, query=None):
|
|
203
|
+
return self.request("GET", paper_path(paper_id, resource, item_id), query=query)
|
|
204
|
+
|
|
205
|
+
def upload_pdf(self, file, *, fields=None, mineru_key=None):
|
|
206
|
+
return self.request("POST", "/pdf/parse/upload", file=file, fields=fields, mineru_key=mineru_key)
|
|
207
|
+
|
|
208
|
+
def pdf_task(self, task_id, *, result=False):
|
|
209
|
+
if task_id == "upload":
|
|
210
|
+
raise ValueError("task_id cannot be upload")
|
|
211
|
+
return self.request("GET", "/pdf/parse/" + segment(task_id) + ("/result" if result else ""))
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Shared local configuration for CLI, SDK and installed Skill."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigError(ValueError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def config_path():
|
|
14
|
+
override = os.environ.get("SCHOLAR_CONFIG_FILE")
|
|
15
|
+
if override:
|
|
16
|
+
return Path(override).expanduser()
|
|
17
|
+
if os.name == "nt":
|
|
18
|
+
root = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData/Local")))
|
|
19
|
+
else:
|
|
20
|
+
root = Path.home() / ".config"
|
|
21
|
+
return root / "linxsci-scholar" / "config.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_url(value):
|
|
25
|
+
if not isinstance(value, str):
|
|
26
|
+
raise ConfigError("服务地址必须是文本。")
|
|
27
|
+
value = value.strip().rstrip("/")
|
|
28
|
+
try:
|
|
29
|
+
url = urlsplit(value)
|
|
30
|
+
port = url.port
|
|
31
|
+
except ValueError:
|
|
32
|
+
raise ConfigError("服务地址格式错误,请填写 http(s) 服务根地址。") from None
|
|
33
|
+
if (url.scheme not in ("http", "https") or not url.hostname or url.username is not None
|
|
34
|
+
or url.password is not None or url.query or url.fragment
|
|
35
|
+
or any(c.isspace() or ord(c) < 32 for c in value)):
|
|
36
|
+
raise ConfigError("服务地址必须是 http(s) 根地址,不能包含账号、查询参数或空白。")
|
|
37
|
+
if url.path.endswith(("/openapi/v2", "/openapi/v1")):
|
|
38
|
+
raise ConfigError("服务地址不应包含 /openapi/v2 或 /openapi/v1。")
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_key(value):
|
|
43
|
+
if not isinstance(value, str) or not value.strip() or any(c in value for c in "\r\n"):
|
|
44
|
+
raise ConfigError("API Key 不能为空或包含换行。请重新配置。")
|
|
45
|
+
return value.strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def service_defaults():
|
|
49
|
+
try:
|
|
50
|
+
values = json.loads((Path(__file__).resolve().parents[1] / "service.json").read_text(encoding="utf-8"))
|
|
51
|
+
return {key: validate_url(values[key]) for key in ("base_url", "online_base_url")}
|
|
52
|
+
except (OSError, KeyError, TypeError, ValueError):
|
|
53
|
+
raise ConfigError("安装包的服务配置损坏,请重新安装。") from None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_config():
|
|
57
|
+
path = config_path()
|
|
58
|
+
if not path.exists():
|
|
59
|
+
return {}
|
|
60
|
+
try:
|
|
61
|
+
values = json.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
except (OSError, ValueError):
|
|
63
|
+
raise ConfigError("无法读取本地配置,请运行 setup 重新配置。") from None
|
|
64
|
+
if not isinstance(values, dict):
|
|
65
|
+
raise ConfigError("本地配置格式错误,请运行 setup 重新配置。")
|
|
66
|
+
result = {}
|
|
67
|
+
if "base_url" in values:
|
|
68
|
+
result["base_url"] = validate_url(values["base_url"])
|
|
69
|
+
if "api_key" in values:
|
|
70
|
+
result["api_key"] = validate_key(values["api_key"])
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_settings(base_url=None, api_key=None):
|
|
75
|
+
# Explicit arguments > environment > saved configuration > release defaults.
|
|
76
|
+
base_url = base_url or os.environ.get("SCHOLAR_BASE_URL")
|
|
77
|
+
api_key = api_key or os.environ.get("SCHOLAR_OPENAPI_KEY")
|
|
78
|
+
saved = load_config() if not (base_url and api_key) else {}
|
|
79
|
+
base_url = base_url or saved.get("base_url") or service_defaults()["base_url"]
|
|
80
|
+
api_key = api_key or saved.get("api_key")
|
|
81
|
+
if not api_key:
|
|
82
|
+
raise ConfigError("尚未配置 API Key。请运行 linxsci setup,或安装包内的 install.py。")
|
|
83
|
+
return validate_url(base_url), validate_key(api_key)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save_config(base_url, api_key):
|
|
87
|
+
values = {"base_url": validate_url(base_url), "api_key": validate_key(api_key)}
|
|
88
|
+
path = config_path()
|
|
89
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
90
|
+
if path.is_symlink():
|
|
91
|
+
raise ConfigError("配置文件不能是符号链接,请选择普通配置文件。")
|
|
92
|
+
fd, temporary = tempfile.mkstemp(prefix=".config-", dir=path.parent)
|
|
93
|
+
try:
|
|
94
|
+
if os.name != "nt":
|
|
95
|
+
os.fchmod(fd, 0o600)
|
|
96
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
97
|
+
json.dump(values, stream, ensure_ascii=False, indent=2)
|
|
98
|
+
stream.write("\n")
|
|
99
|
+
os.replace(temporary, path)
|
|
100
|
+
finally:
|
|
101
|
+
if os.path.exists(temporary):
|
|
102
|
+
os.unlink(temporary)
|
|
103
|
+
return path
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def clear_config():
|
|
107
|
+
path = config_path()
|
|
108
|
+
if path.is_symlink():
|
|
109
|
+
raise ConfigError("配置文件不能是符号链接。")
|
|
110
|
+
path.unlink(missing_ok=True)
|
|
111
|
+
return path
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Install the bundled Skill without pip or network access."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
SKILL_NAME = "linxsci-skills"
|
|
10
|
+
LEGACY_SKILL_NAME = "linxsci-scholar"
|
|
11
|
+
MARKER = ".linxsci-install.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def default_skills_dir():
|
|
15
|
+
return Path.home() / ".agents" / "skills"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def install_skill(skills_dir=None, update=False):
|
|
19
|
+
source = Path(__file__).resolve().parents[1]
|
|
20
|
+
parent = Path(skills_dir).expanduser() if skills_dir else default_skills_dir()
|
|
21
|
+
destination = parent / SKILL_NAME
|
|
22
|
+
legacy = parent / LEGACY_SKILL_NAME
|
|
23
|
+
if destination.is_symlink():
|
|
24
|
+
raise ValueError("目标 Skill 是符号链接,请选择其他安装目录。")
|
|
25
|
+
if legacy.is_symlink():
|
|
26
|
+
raise ValueError("旧 Skill 是符号链接,请使用原安装工具管理它,或选择其他目录。")
|
|
27
|
+
if destination.exists() and legacy.exists():
|
|
28
|
+
raise ValueError("检测到新旧两个 Skill 目录,请先将不再使用的副本备份到助手扫描目录之外。")
|
|
29
|
+
existing = destination if destination.exists() else legacy
|
|
30
|
+
if existing.exists():
|
|
31
|
+
if not update:
|
|
32
|
+
raise ValueError("Skill 已存在。更新或迁移本工具安装的版本请使用 --update;自定义安装请先备份。")
|
|
33
|
+
marker = existing / MARKER
|
|
34
|
+
try:
|
|
35
|
+
installed = json.loads(marker.read_text(encoding="utf-8"))
|
|
36
|
+
except (OSError, ValueError):
|
|
37
|
+
raise ValueError("目标目录不是本安装工具管理的 Skill,请先备份或选择其他目录。") from None
|
|
38
|
+
if not isinstance(installed, dict) or installed.get("name") != existing.name:
|
|
39
|
+
raise ValueError("安装标记不匹配,请选择其他目录。")
|
|
40
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
staging = Path(tempfile.mkdtemp(prefix=".linxsci-stage-", dir=parent))
|
|
42
|
+
backup = None
|
|
43
|
+
try:
|
|
44
|
+
shutil.copytree(source, staging, dirs_exist_ok=True,
|
|
45
|
+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", MARKER))
|
|
46
|
+
(staging / MARKER).write_text(json.dumps({"name": SKILL_NAME, "version": "0.3.0"}), encoding="utf-8")
|
|
47
|
+
if existing.exists():
|
|
48
|
+
backup_root = parent.parent / "linxsci-skill-backups"
|
|
49
|
+
backup_root.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
backup = backup_root / (existing.name + ".backup-" + uuid.uuid4().hex[:8])
|
|
51
|
+
os.replace(existing, backup)
|
|
52
|
+
try:
|
|
53
|
+
os.replace(staging, destination)
|
|
54
|
+
except OSError:
|
|
55
|
+
if backup:
|
|
56
|
+
os.replace(backup, existing)
|
|
57
|
+
backup = None
|
|
58
|
+
raise
|
|
59
|
+
finally:
|
|
60
|
+
if staging.exists():
|
|
61
|
+
shutil.rmtree(staging)
|
|
62
|
+
return destination, backup
|
linxsci/service.json
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linxsci-sdk
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Scholar OpenAPI v2 CLI and Python client
|
|
5
|
+
Project-URL: Homepage, https://linxsci.com
|
|
6
|
+
Project-URL: Repository, https://github.com/LinxSci/linxsci-skills
|
|
7
|
+
Project-URL: Documentation, https://github.com/LinxSci/linxsci-skills/blob/main/README.md
|
|
8
|
+
Project-URL: Issues, https://github.com/LinxSci/linxsci-skills/issues
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# LinxSci SDK、CLI 与 Skills
|
|
13
|
+
|
|
14
|
+
让 AI 助手帮你搜索学术论文、阅读相关章节、查询引用,并解析你指定的 PDF。
|
|
15
|
+
|
|
16
|
+
你需要一个已有的 Scholar API Key,以及能够执行本地 Python 脚本的 AI 助手。运行环境需要 **Python 3.9+**;本工具不需要额外 Python 运行依赖。
|
|
17
|
+
|
|
18
|
+
## 命令和 Skill 如何命名
|
|
19
|
+
|
|
20
|
+
| 层级 | 当前名称或用法 |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| PyPI 安装包名 | `linxsci-sdk` |
|
|
23
|
+
| 终端命令名 | `linxsci`,例如 `linxsci search "graph neural networks"` |
|
|
24
|
+
| Python 导入名 | `linxsci`,例如 `from linxsci import ScholarClient` |
|
|
25
|
+
| AI Skill 名称 | `linxsci-skills`,在助手中使用 `$linxsci-skills` |
|
|
26
|
+
|
|
27
|
+
当前论文相关动作直接使用 `linxsci <动作>`,例如 `linxsci setup`、`linxsci search`、`linxsci paper`。单独执行 `linxsci` 显示帮助。未来新增能力可注册独立命令组,例如 `linxsci patents search`;这是命名示例,当前只提供 Scholar API 能力。
|
|
28
|
+
|
|
29
|
+
本文统一使用表中的新名称。已有 Key 配置和 `SCHOLAR_*` 环境变量继续复用;安装过旧名称包的开发者先按 [名称迁移说明](docs/DEVELOPMENT.md#14-从旧名称迁移) 更新,避免两个包共用同一终端入口。新增能力的开发步骤见 [开发者指南](docs/DEVELOPMENT.md#91-新增其他-skill-的命名与入口)。
|
|
30
|
+
|
|
31
|
+
## 对外发布路线
|
|
32
|
+
|
|
33
|
+
通过 **PyPI 发布 CLI / SDK,通过官方 GitHub 仓库维护和分发 Skill**。源码已上传至 [LinxSci/linxsci-skills](https://github.com/LinxSci/linxsci-skills),PyPI 首次发布仍在准备中。以下包名安装命令仅在 PyPI 发布成功后适用;发布前可使用本地 wheel、ZIP 或源码安装。
|
|
34
|
+
|
|
35
|
+
CLI 的推荐安装与使用方式:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv tool install linxsci-sdk
|
|
39
|
+
linxsci setup
|
|
40
|
+
linxsci search "graph neural networks"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
升级使用 `uv tool upgrade linxsci-sdk`;找不到命令时按 [uv 官方工具指南](https://docs.astral.sh/uv/guides/tools/) 使用 `uv tool update-shell`,然后重新打开终端。SDK 用户应在自己的 Python 项目环境中执行 `python -m pip install linxsci-sdk`;`uv tool install` 的隔离环境不会把 SDK 安装到当前 Python 项目。
|
|
44
|
+
|
|
45
|
+
Skill 官方仓库为 [LinxSci/linxsci-skills](https://github.com/LinxSci/linxsci-skills),可使用支持 GitHub Skill 的安装器:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx skills add https://github.com/LinxSci/linxsci-skills --skill linxsci-skills
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
该方式需要 Node.js / npx,按提示选择目标助手和安装范围。已安装本 CLI 的用户也可以运行 `linxsci install-skill` 安装随包携带的完整 Skill。安装后按 [普通用户指南](docs/USER_GUIDE.md) 配置 Key。官网接入步骤见 [官网接入说明](docs/WEBSITE_PUBLISHING.md) 与 [发布指南](docs/RELEASING.md)。
|
|
52
|
+
|
|
53
|
+
## 普通用户:安装、配置、提问
|
|
54
|
+
|
|
55
|
+
1. 下载发布方提供的 `linxsci-skills-0.3.0.zip`,解压。
|
|
56
|
+
2. macOS 双击 `Install.command`;Windows 双击 `Install.bat`。也可以在解压目录运行 `python3 install.py`(Windows 用 `py -3 install.py`)。
|
|
57
|
+
3. 在向导中确认服务地址,输入你的 API Key。输入时 Key 不会显示。向导保存配置并检查连接。
|
|
58
|
+
4. 在助手中输入:
|
|
59
|
+
|
|
60
|
+
> 使用 $linxsci-skills 搜索图神经网络相关论文,阅读方法章节,比较主要方法并附上来源。
|
|
61
|
+
|
|
62
|
+
默认使用正式服务 **https://linxsci.com**,无需在用户电脑部署 Scholar。已有本地配置或环境变量优先于默认值,升级不会自动替换用户保存的测试地址。切换正式服务可运行 `linxsci setup --online`;ZIP 用户在解压目录运行 `python3 skills/linxsci-skills/scripts/cli.py setup --online`。测试环境配置见 [测试指南](docs/TESTING.md)。
|
|
63
|
+
|
|
64
|
+
安装器默认将 Skill 放到 `~/.agents/skills/linxsci-skills`,见 [官方本地 Skill 说明](https://learn.chatgpt.com/docs/build-skills)。客户端未发现 Skill 时重启助手;其他加载位置可通过 `--skills-dir` 指定。旧版客户端使用 `~/.codex/skills` 时,也可显式指定该目录。
|
|
65
|
+
|
|
66
|
+
ZIP 安装后即可在助手中使用 Skill;安装器不注册终端的 `linxsci` 命令。需要该命令时,按下面的 wheel 或源码安装流程操作。完整操作步骤、更新和故障处理见 [普通用户指南](docs/USER_GUIDE.md)。本项目不包含 AI 助手或 Scholar 服务器,也不负责注册账号或发放 Key。
|
|
67
|
+
|
|
68
|
+
## 已安装 CLI 的用户
|
|
69
|
+
|
|
70
|
+
当前发布文件可本地安装;尚未上传到 PyPI。安装 wheel 后执行:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
linxsci install-skill
|
|
74
|
+
linxsci setup --base-url https://linxsci.com
|
|
75
|
+
linxsci search 'graph neural networks'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`linxsci setup` 一次保存配置,后续 CLI、SDK 和本地 Skill 共用。不需要每次 export 环境变量。`linxsci config` 查看配置状态,不显示 Key;`linxsci config --clear` 清除保存的地址和 Key。更新包后运行 `linxsci install-skill --update` 更新助手使用的脚本,旧 Skill 保留备份。
|
|
79
|
+
|
|
80
|
+
逐个接口的完整命令、请求文件、ID 编码及 PDF 上传方式见 [全部 34 个接口的 CLI 使用指南](docs/CLI_GUIDE.md)。
|
|
81
|
+
|
|
82
|
+
## 从本地源码开始测试
|
|
83
|
+
|
|
84
|
+
在项目根目录执行。首次创建环境并安装开发版;已有 `.venv` 时可跳过创建:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
python3 -m venv .venv
|
|
88
|
+
source .venv/bin/activate
|
|
89
|
+
python3 -m pip install --upgrade pip setuptools wheel
|
|
90
|
+
python3 -m pip install -e .
|
|
91
|
+
|
|
92
|
+
linxsci --version
|
|
93
|
+
linxsci setup --base-url http://101.126.93.213:28888
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Windows 激活命令为 `.venv\Scripts\activate`。统一使用同一个虚拟环境,不要混用 `.venv` 和 `.venv-1`。旧 pip 可能提示找不到 setup.py/setup.cfg,先执行上述升级命令;指定解释器安装时使用 `.venv/bin/python -m pip install -e .`,不要写成 `python -m python3 -m pip ...`。
|
|
97
|
+
|
|
98
|
+
从旧名称的开发版本更新时,先按 [名称迁移说明](docs/DEVELOPMENT.md#14-从旧名称迁移) 卸载旧包,再安装当前源码。用 `linxsci --version` 和 `linxsci --help` 确认入口。已有 Key 无需重新输入;`linxsci install-skill --update` 会将本工具管理的旧 Skill 迁移到新目录并备份。
|
|
99
|
+
|
|
100
|
+
按向导输入已有 API Key,输入时不会显示。`setup` 会检查一次连接,成功后即可检索;之后需要单独检查连接时运行 `linxsci doctor --json`,确认 `ok` 为 true 且 `base_url` 是测试服务地址。检查连接也会实际调用接口,无需反复执行。
|
|
101
|
+
|
|
102
|
+
## 论文检索和阅读示例
|
|
103
|
+
|
|
104
|
+
以下命令在已经激活虚拟环境、完成 Key 配置后执行。CLI 会输出服务返回的数据,整理和比较论文可以交给安装了 Skill 的助手。
|
|
105
|
+
|
|
106
|
+
### 1. 按英文或中文主题检索
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
linxsci search "graph neural networks"
|
|
110
|
+
linxsci search "大语言模型智能体"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
将引号中的文字替换为自己的研究主题。检查返回论文的标题、摘要和主题是否相关,记录实际返回的论文 ID;未检索到结果时不应编造论文。
|
|
114
|
+
|
|
115
|
+
### 2. 保存搜索结果
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
linxsci --output search.json search "graph neural networks"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
响应原始内容保存到当前目录的 `search.json`,可用编辑器打开核查。`--output` 是全局选项,放在 `linxsci` 之后、`search` 或 `paper` 等具体动作之前。
|
|
122
|
+
|
|
123
|
+
### 3. 查看论文详情、目录和相关章节
|
|
124
|
+
|
|
125
|
+
先从搜索响应选择一篇论文,再从其目录选择章节。下列中文占位参数必须替换为真实返回值后执行:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
linxsci paper "实际论文ID"
|
|
129
|
+
linxsci paper "实际论文ID" --resource toc
|
|
130
|
+
linxsci paper "实际论文ID" --resource sections --item-id "实际章节ID"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
论文 ID 保留 `local:`、`arxiv:`、`openalex:`、`s2:` 或 `doi:` 来源前缀,不要用论文标题代替 ID。便捷命令接收未编码的 ID,并负责 URL 编码;章节 ID 必须来自接口响应,不能根据章节名称猜测。
|
|
134
|
+
|
|
135
|
+
测试通过的标准是:详情对应所选论文,目录能定位真实章节,读取的章节内容与所选 ID 一致。不要仅凭接口未报错就判断内容正确。
|
|
136
|
+
|
|
137
|
+
## 在助手中测试 Skill
|
|
138
|
+
|
|
139
|
+
源码安装只提供 CLI/SDK。需要通过 `$linxsci-skills` 使用时,再安装 Skill;首次执行:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
linxsci install-skill
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
已由本工具安装过,或修改源码后要同步到安装副本,执行:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
linxsci install-skill --update
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
在支持本地 Skill 和脚本执行的助手中新建对话,分别尝试:
|
|
152
|
+
|
|
153
|
+
**检索与筛选**
|
|
154
|
+
|
|
155
|
+
> 使用 $linxsci-skills 检索图神经网络相关论文,选择最多三篇最相关的,说明相关性,并附实际返回的论文 ID 和来源。结果不足三篇时请如实说明。
|
|
156
|
+
|
|
157
|
+
**按章节阅读**
|
|
158
|
+
|
|
159
|
+
> 使用 $linxsci-skills 阅读论文“实际论文ID”,先查看目录,再读取方法相关章节,解释主要方法,并标明章节依据。
|
|
160
|
+
|
|
161
|
+
**比较两篇论文**
|
|
162
|
+
|
|
163
|
+
> 使用 $linxsci-skills 比较论文“实际论文ID一”和“实际论文ID二”的方法和实验结论,逐项列出来源。未找到证据的数据请明确说明,不要补写。
|
|
164
|
+
|
|
165
|
+
将提示中的论文 ID 替换为实际检索结果。检查助手是否真正调用服务、引用是否能对应响应、数值是否有章节证据,以及是否存在重复检索或无目的读取全文。
|
|
166
|
+
|
|
167
|
+
开发时也可在新对话中直接指定当前仓库 `skills/linxsci-skills/SKILL.md` 的绝对路径,要求助手读取该文件并执行同目录下的脚本,从而直接测试当前源码。复制安装的 Skill 不会随源码自动更新,修改后要先同步,避免误测旧版本。
|
|
168
|
+
|
|
169
|
+
## 测试样例与验收重点
|
|
170
|
+
|
|
171
|
+
| 测试样例 | 验收重点 |
|
|
172
|
+
| --- | --- |
|
|
173
|
+
| 英文、中文关键词检索 | 执行真实搜索,返回相关论文;空结果如实说明 |
|
|
174
|
+
| 保存搜索结果 | `search.json` 存在,保留服务原始响应 |
|
|
175
|
+
| 论文详情与目录 | 使用真实且带来源前缀的 ID,内容与论文对应 |
|
|
176
|
+
| 读取指定章节 | 使用目录返回的实际章节 ID,证据能定位到正文 |
|
|
177
|
+
| 助手筛选和比较 | 有真实调用记录,论文来源和结论可核查,不编造数值 |
|
|
178
|
+
|
|
179
|
+
本地修改可直接连接测试环境验证,无需每次先 push Git 或重新打包。调优前后固定提示词和模型设置,记录结果准确性、调用次数、耗时以及需要人工纠正的地方。完整的 8 个验收场景、故障处理及记录模板见 [测试环境验收与 Skill 调优](docs/TESTING.md)。
|
|
180
|
+
|
|
181
|
+
## 能力与范围
|
|
182
|
+
|
|
183
|
+
已适配新版数字框架错误码、请求追踪和计费异常诊断。覆盖接口文档中的 34 条路由:服务信息和健康、论文搜索和标识解析、详情/内容/目录/章节/图表资源/引用、推荐、作者/机构/期刊、PDF 解析任务。全部 34 个接口均提供便捷命令,无需填写 HTTP 方法或接口路径;`request` 保留作通用调试入口。
|
|
184
|
+
|
|
185
|
+
| 接口分类 | 数量 | 调用入口 |
|
|
186
|
+
| --- | --- | --- |
|
|
187
|
+
| 服务信息与健康检查 | 4 | `meta`、`usage`、`health live/ready` |
|
|
188
|
+
| 论文搜索与解析 | 6 | `search`、`search-openalex`、`search-semantic-scholar`、`search-google-scholar`、`resolve`、`batch-get` |
|
|
189
|
+
| 论文详情与引用 | 11 | `paper`,搭配 `--resource` 和 `--item-id` |
|
|
190
|
+
| 论文推荐 | 2 | `recommend`、`mobile-cards` |
|
|
191
|
+
| 作者、机构与期刊 | 7 | `author`、`institution`、`venue`(可加 `--papers`)、`resolve-entities` |
|
|
192
|
+
| PDF 解析 | 4 | `parse-pdf`、`upload`、`task`(结果加 `--result`) |
|
|
193
|
+
|
|
194
|
+
只需调用指定接口时,可以直接使用便捷命令。例如:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
linxsci mobile-cards
|
|
198
|
+
linxsci search-openalex "large language models"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
四个搜索命令都直接接收检索词,可通过 `--body @文件.json` 添加契约中定义的其他参数。其他 JSON 请求文件需先按实际 Agent API 契约准备。便捷命令自动选择接口方法和路径;JSON 请求可通过 `--body @文件路径` 传入请求。PDF 上传为 multipart,使用 `linxsci upload /实际路径/paper.pdf`。在助手中也可直接提出“使用 $linxsci-skills 调用指定接口”,并提供接口路径及必要参数。
|
|
202
|
+
|
|
203
|
+
研究比较和总结由助手根据实际读取的内容完成。目前接口文档未定义多数 POST 的业务字段、分页和 PDF 状态模型;这些高级接口应按部署版本的上游契约使用,不能假定空 JSON 有效。所有路由可调用不代表每条接口都已有专用表单或经过真实生产验证。
|
|
204
|
+
|
|
205
|
+
## 开发与发布
|
|
206
|
+
|
|
207
|
+
- [SDK 开发者指南:安装、配置、方法签名与全部 34 个接口](docs/DEVELOPMENT.md)
|
|
208
|
+
- [CLI 使用指南:全部 34 个接口逐一调用](docs/CLI_GUIDE.md)
|
|
209
|
+
- [测试环境验收与 Skill 调优](docs/TESTING.md)
|
|
210
|
+
- [发布指南:构建、验证与分发](docs/RELEASING.md)
|
|
211
|
+
- [官网接入说明:下载入口、三类文档与发布文件](docs/WEBSITE_PUBLISHING.md)
|
|
212
|
+
- [Skill 指令](skills/linxsci-skills/SKILL.md)
|
|
213
|
+
- [原始接口文档](Scholar-OpenAPI-v2-使用文档.md)
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
python3 -m unittest discover -s tests -v
|
|
217
|
+
python3 tools/build_release.py --bundle-only
|
|
218
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
scholar.py,sha256=CCetGJAtlmjHIpIuNCvNBwDvx8jWe_8vKwMVxD07SKI,159
|
|
2
|
+
scholar_client.py,sha256=C4hem7vZFFO2BujxEorpbAMLJORjBbsU2DDMGI_0ONQ,136
|
|
3
|
+
linxsci/SKILL.md,sha256=9QxaHGK4JEks92E-H_6co2haRkDANcsEgv1WkDduEAg,8274
|
|
4
|
+
linxsci/__init__.py,sha256=WfeJ1Bn90kQxP5W_QlbjMIa7EWClVES-IZLNGMvtOFs,211
|
|
5
|
+
linxsci/service.json,sha256=DiTX14q2fqvTcM7I2d1q8beSlQn8KFm2nlHqfB-i4uI,84
|
|
6
|
+
linxsci/agents/openai.yaml,sha256=YaHCHax3_Oozjb1sA52AwkW0RCBtt3HH12JzxmNOYes,300
|
|
7
|
+
linxsci/references/api.md,sha256=bzmsRnOdKpwMr_2m5biT7UUgijVU-gS5eRa8Lj-l4TQ,8168
|
|
8
|
+
linxsci/references/cli.md,sha256=FMzN-1qpEUWD0AnqgkqzpZQAWog8rKzvfJfIqF_Gyuo,3515
|
|
9
|
+
linxsci/scripts/__init__.py,sha256=eqGdpTajCBo_D5l4fypYmUg-qgUypo0w1v5apB52o8Q,56
|
|
10
|
+
linxsci/scripts/cli.py,sha256=hqKZu6SRVYqWwqc5Et6MDP9EaaJqDoT63cLM7E6lyaY,1272
|
|
11
|
+
linxsci/scripts/scholar.py,sha256=1ecmWa9t6lvOp3q1HPF-Z9Bug3QUz2dWwqMy22VVHQI,16684
|
|
12
|
+
linxsci/scripts/scholar_client.py,sha256=s0lrEFqLEQ3BiXCShUiprmi8aXPX6-bfzs36040ATCc,10145
|
|
13
|
+
linxsci/scripts/scholar_config.py,sha256=5ULS2uEG703IH17ySZ-IQ_UheT0hgak_t3D7RGhj0rE,4247
|
|
14
|
+
linxsci/scripts/scholar_install.py,sha256=a4z0FJWpdKqdD_CvI-a_rUfyrI_xDfik4b-o_LDQmXs,2741
|
|
15
|
+
linxsci_sdk-0.3.0.dist-info/METADATA,sha256=ONuoRVvxxaMTRcT8gZDdED3OmxdMIHoxt6g3TKOzIlw,13454
|
|
16
|
+
linxsci_sdk-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
17
|
+
linxsci_sdk-0.3.0.dist-info/entry_points.txt,sha256=mFFFi_qpG3p6U_ohN1FNBNjwAPEL6Ua12dX5TWE-WAU,53
|
|
18
|
+
linxsci_sdk-0.3.0.dist-info/top_level.txt,sha256=ZpgyGVDAJHMUuY6mLdTBDapgwOuq1q40sgiJ8nCVv94,31
|
|
19
|
+
linxsci_sdk-0.3.0.dist-info/RECORD,,
|
scholar.py
ADDED
scholar_client.py
ADDED