Package Paper Atlas with organized modules and a unified CLI
Metadata integrity checks / metadata (push) Successful in 1m12s

This commit is contained in:
2026-11-06 17:32:24 +08:00
parent dc7be9d6a4
commit 1c18cbe03a
62 changed files with 835 additions and 518 deletions
+17 -3
View File
@@ -26,10 +26,24 @@ jobs:
root = pathlib.Path(tmp)
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
tar.extractall(root, filter='data')
candidates = list(root.glob('*/test_import_arxiv.py'))
candidates = list(root.glob('*/pyproject.toml'))
if len(candidates)!=1:
raise SystemExit('Unexpected repository archive layout')
src = candidates[0].parent
subprocess.run(['python3','-B','-m','unittest','discover','-v'],cwd=src,check=True)
subprocess.run(['python3','-B','query.py','--help'],cwd=src,check=True)
subprocess.run(['python3','-B','-m','unittest','discover','-s','tests','-t','.','-v'],cwd=src,check=True)
# Build and installation checks run only on the approved remote runner.
venv = root / 'venv'
subprocess.run(['python3','-B','-m','venv','--system-site-packages',str(venv)],check=True)
python = str(venv / 'bin/python')
wheels = root / 'wheels'
subprocess.run([python,'-B','-m','pip','wheel','--no-deps','--wheel-dir',str(wheels),str(src)],check=True)
wheel, = wheels.glob('paper_atlas-*.whl')
subprocess.run([python,'-B','-m','pip','install','--no-deps',str(wheel)],check=True)
outside = root / 'outside-checkout'
outside.mkdir()
subprocess.run([str(venv/'bin/paper-atlas'),'--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','cvf','--help'],cwd=outside,check=True)
smoke = "from paper_atlas.db.store import Store; from paper_atlas.config import DEFAULT_REGISTRY; import json; assert len(json.loads(DEFAULT_REGISTRY.read_text())['venues'])==41; s=Store('installed.sqlite'); s.conn.close()"
subprocess.run([python,'-I','-B','-c',smoke],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','query','--db','installed.sqlite','--stats'],cwd=outside,check=True)
PY
+4
View File
@@ -1,4 +1,8 @@
__pycache__/
.venv/
build/
dist/
*.egg-info/
*.pyc
*.sqlite
*.sqlite-*
+58 -286
View File
@@ -1,313 +1,85 @@
# Paper Atlas · 论文元数据与投稿历史索引
# Paper Atlas
Paper Atlas 是一个**长期维护、按需或按批次运行的研究数据工具项目**。它不是一次性的下载脚本,也不需要为了采集而持续启动 Web 服务。目标是把 arXiv、重要 AI 会议论文集及公开投稿决定整合成可追溯、可查询、可持续更新的数据库,供后续研究趋势、跨会投稿、主题演化等分析使用。
**长期维护、按需运行的论文元数据与公开投稿历史工具。**
## 项目目标与当前进度
Paper Atlas 将 arXiv、AI 会议论文集及公开投稿决定整理到可追溯的 SQLite 数据库中,用于查询和后续研究分析。它按批次运行,不需要常驻 Web 服务;采集标题、摘要、标识、标签及决定证据,不下载论文全文。
目标包括:完整历史元数据导入、逐年会议目录、跨平台论文去重、公开拒稿/撤稿信息、平台标签与自定义标签隔离、可恢复更新和覆盖率报告。
项目仍在建设中。候选范围包含 41 个会议,尚未完成全部历史数据和最终去重复核。实际进度以数据库、来源清单和覆盖报告为准,而不是仓库文件数量。
**目前仍在开发,不能视为完整数据集。** 已实现 SQLite 数据结构、arXiv 元数据 ZIP 流式导入、断点恢复、跨平台标识合并及审计、PMLR、COLM、NeurIPS、ACL Anthology、ICLR 官方公开程序、CVF/ECVA 视觉会议元数据采集、带清单核验的会议导入和只读查询。PeerRead 提供公开 ICLR 2017 明确决定的历史补充;近期 ICLR 公开决定快照已解析,实际主库进度以运行日志与统计为准。arXiv 元数据 ZIP 已完整下载(1,878,831,586 字节),主批次读取 3,233,620 行至 EOF;主库有 3,233,527 条 arXiv 来源记录,93 行差额仍需按原始 ID 独立核验。这些不是去重后的全库论文数,最终全库审计尚未完成。其他会议适配器、历史缺失届次、完整投稿历史、跨平台疑似重复复核和覆盖率核验仍待完成。回归检查在远端执行,已配置 ces-control:host 对应的 CI 工作流,实际 CI 结果以 Gitea Actions 为准。`venues.json` 的 41 个会议是候选覆盖范围,不代表已完成采集,也不是与 COLM 比较的客观排名。
## 项目结构
## 数据范围与来源
- arXiv:官方推荐的 Cornell/Kaggle 元数据快照,包含标识、作者、标题、摘要、类别、版本与外部索引。默认导入整个快照,通过类别筛选 AI 论文,避免永久丢弃交叉领域记录。
- 会议论文集:优先官方来源,计划接入 OpenReview、PMLR、ACL Anthology、NeurIPS、CVF、ECVA、AAAI、IJCAI;DBLP 用于补充会议目录、标识与出版信息。GitHub 聚合名单只作发现线索,不能作为未经核验的决定证据。
- 投稿历史:只收录公开可访问并有证据的记录;没有录用信息不等于拒稿,不能据此推断“从未中会”。
- 当前不下载 PDF、LaTeX、图片或补充材料,不提取论文全文,不推断匿名作者身份。
官方入口:
- [arXiv bulk metadata](https://info.arxiv.org/help/bulk_data/index.html)
- [arXiv metadata snapshot](https://www.kaggle.com/datasets/Cornell-University/arxiv)
- [OpenReview 数据接口说明](https://docs.openreview.net/how-to-guides/data-retrieval-and-modification/how-to-get-all-notes-for-submissions-reviews-rebuttals-etc)
- [PMLR](https://proceedings.mlr.press/)
- [ACL Anthology 权威 XML 与 API](https://aclanthology.org/info/development/)
- [NeurIPS](https://proceedings.neurips.cc/)
- [COLM](https://proceedings.colm.cc/)
- [CVF](https://openaccess.thecvf.com/)
- [ECVA](https://www.ecva.net/papers.php)
- [IJCAI](https://www.ijcai.org/all_proceedings/)
- [AAAI](https://ojs.aaai.org/index.php/AAAI/issue/archive)
- [DBLP](https://dblp.org/db/conf/)
## 候选会议覆盖目录
| 领域 | 会议系列 | 主要发现入口 |
| --- | --- | --- |
| 机器学习、理论 | NeurIPS、ICML、ICLR、COLM、COLT、AISTATS、UAI、CLeaR | OpenReview、PMLR、NeurIPS、COLM 官方论文集 |
| 自然语言 | ACL、EMNLP、NAACL、EACL、COLING、CoNLL | ACL Anthology |
| 视觉 | CVPR、ICCV、ECCV | CVF、ECVA |
| 综合 AI | AAAI、IJCAI、ECAI | 官方论文集、DBLP |
| 检索与数据 | KDD、SIGIR、WWW、WSDM、RecSys、CIKM、ICDM、SDM、ECML-PKDD | 官方出版方、DBLP,具体年份入口待逐项登记 |
| 机器人 | ICRA、IROS、RSS、CoRL | 官方论文集、PMLR、DBLP |
| 规划、知识与智能体 | ICAPS、KR、AAMAS | 官方论文集、DBLP |
| 语音与音频 | ICASSP、INTERSPEECH、ISMIR | 官方论文集、ISCA、DBLP |
| 相关专门领域 | MICCAI、FAccT | 官方出版方、DBLP |
这些是领域覆盖候选,不构成统一排名,也不能据此声称已全部入库。逐年目录以真实举办届次和正式出版清单为准:双年会议不补造不存在的年份;主会、Findings、workshop 分开。`edition_sources` 保存实际采集状态,不能用候选名单代替覆盖率报告。
## 数据模型
| 表 | 用途 |
| --- | --- |
| `papers` / `papers_fts` | 稳定论文实体、代表性标题摘要、FTS5 全文检索 |
| `identifiers` | arXiv ID、DOI 等唯一外部标识 |
| `source_records` / `sources` | 平台记录、原平台标题摘要与索引元数据、来源 URL 和抓取时间 |
| `venues` / `venue_aliases` / `editions` | 会议或期刊系列、年份与 track,区分主会、Findings 和 workshop,联合会议用别名查询,不重复生成事件 |
| `edition_sources` | 每届每个来源的覆盖状态、预计/已导入数量、错误与断点 |
| `submissions` / `submission_identifiers` / `submission_sources` | 投稿事件、按届次限定的原平台强标识、多来源证据;同稿的不同投稿尝试分别保留 |
| `decisions` / `decision_sources` | 明确决定、原始决定日期、来源观察日期及证据,不把缺失决定编造成拒稿 |
| `submission_merges` / `decision_merges` | 投稿事件与重复决定观察的合并审计,保留来源关联并避免重用已合并的编号 |
| `tags` / `paper_tags` | 平台标签与人工标签,使用命名空间隔离 |
| `match_candidates` / `paper_merges` | 潜在重复复核状态、确定合并审计 |
| `ingest_runs` | 导入批次、状态、错误和恢复位置 |
决定状态为 `accepted`、`rejected`、`desk_rejected`、`withdrawn`、`under_review`、`unknown`。一篇论文可以对应多次投稿和多个决定,不能用单一“最终会议”字段覆盖历史。查询中按观察时间选择最近状态;后续分析需要同时检查来源覆盖率。
当前确定性去重使用 arXiv ID、单个 DOI 和 OpenReview forum ID;跨平台同标题还需至少两位作者吻合,或作者吻合且摘要相同,才自动合并。保留各平台源记录,避免合并后丢失出处。规范化标题只建检索索引,尚未实现模糊匹配流程;**现阶段不保证跨平台去重已经完成**。不同平台标签不自动等同,自定义标签应使用 `custom:*` 命名空间。
## 运行与查询
基础导入和查询依赖 Python 3 标准库和带 FTS5 的 SQLite;PMLR 采集另需 `requirements.txt` 中锁定的 PyYAML 6.0.3。无项目构建步骤。服务器已检查为 Python 3.14.4。所有运行数据放在仓库外,不提交数据库、下载包或访问凭据。
服务器暂存目录:`/data/paper-index/`,快照目录:`/data/paper-index/raw/`,运行代码目录:`/data/paper-index/code/`。这些路径不是对外访问的 API。本地工作区只用于编辑代码、文档和 Git;采集、全量导入和运行检查均在用户确认的服务器执行。
下载完成并验证 ZIP 后,在服务器运行:
```sh
python3 -B import_arxiv.py \
--db /data/paper-index/papers.sqlite \
--archive /data/paper-index/raw/arxiv-metadata.zip
```text
paper_atlas/
cli.py 统一命令入口
collectors/ 各来源采集与目录盘点
ingestion/ 导入、身份回填与单写入协调
db/ 数据模型、身份解析与 SQL schema
audits/ 来源核验和覆盖报告
io/ 网络、快照下载、HTML 与 Git 元数据读取
resources/ 候选会议目录
querying.py 只读查询
tests/ 回归与集成验证
docs/ 使用、数据源、数据模型和维护说明
pyproject.toml 包元数据、依赖和命令注册
.gitea/workflows/ 远程 CI 与安装验证
```
导入直接读取压缩包,不落盘展开整个 JSON。每 500 条提交一次事务并保存断点。若同一快照的导入异常中断,用相同命令恢复;恢复阶段需要重新顺序读取并跳过已提交行。不得同时启动多个写入进程。待恢复批次的文件路径、大小和修改时间必须一致;下载快照校验信息应保存在 `raw/arxiv-manifest.json`。
## 安装与入口
全量导入是有实质磁盘和 I/O 成本的操作。先检查 `df -h`,保持足够空间;数据库、FTS 索引和 WAL 都会增长,压缩包大小不是最终数据库占用。索引和数据库生成属于数据处理,不会构建应用镜像。
以下安装、测试及批次运行在已确认的远程服务器 / `ces-control:host` runner 上执行。已在 Python 3.14 环境验证;项目要求 Python 3.12 及以上。
```sh
# 导入状态、记录量与覆盖状态
python3 -B query.py --db /data/paper-index/papers.sqlite --stats
# 查询标题或摘要,支持 SQLite FTS5 查询语法
python3 -B query.py --db /data/paper-index/papers.sqlite --query '"language model"' --limit 20
# 按 arXiv 平台类别筛选
python3 -B query.py --db /data/paper-index/papers.sqlite --tag arxiv:category=cs.AI
# 会议与决定查询(会议数据导入后才有结果)
python3 -B query.py --db /data/paper-index/papers.sqlite --venue COLM --year 2025 --status rejected
python3 -m venv .venv
.venv/bin/python -m pip install .
source .venv/bin/activate
paper-atlas --help
```
查询连接使用 SQLite 只读模式,结果输出 JSON。`--stats` 中 `papers` 是按当前规则去重后的论文实体数,`records_by_source` 是各平台已入库记录数,`decision_events_by_status` 是决定事件数。采集文件 manifest 的 `count` 是已采集数量,不能等同已入库数量,也不能把各平台记录数相加当成独立论文数。
从源码目录也可以使用同一命令树:`python3 -B -m paper_atlas --help`。无需执行散落的脚本文件。
全部目标会议的覆盖报告会保留尚无入库记录的项目,避免只展示已经做完的会议:
## 常用流程
```sh
python3 -B coverage_report.py --db /data/paper-index/papers.sqlite \
# 查看当前入库量;不同平台记录数不等于独立论文数
paper-atlas query --db /data/paper-index/papers.sqlite --stats
# 查询标题与摘要
paper-atlas query --db /data/paper-index/papers.sqlite --query '"language model"'
# 查看一篇论文的来源、标签和所有已知投稿历史
paper-atlas query --db /data/paper-index/papers.sqlite --paper 123
# 列出全部目标会议,包括尚未入库的项目
paper-atlas coverage --db /data/paper-index/papers.sqlite \
--out /data/paper-index/collected/venue-coverage-current.json
```
报告按会议、年份和 track 列出投稿数、独立论文数及来源清单状态,包含联合会议别名。没有数据的目标明确标为 no_imported_records;已有数据也保持“历史待独立核验”,不会因单个文件导入通过就宣称历年覆盖完成。同一论文可以出现于多个会议或年份,各会议论文数也不能直接相加作为全库数量。
用 `--paper 531782` 可查看单篇论文的所有已知来源、平台标签、投稿届次及带证据的决定历史。会议筛选不区分大小写,状态筛选使用该次投稿的最新已知决定。当前没有公共 Web 页面或网络查询服务。
## 官方会议元数据采集
```sh
# 单卷示例:ICML 2025,读取官方 gh-pages 元数据,不下载论文正文
python3 -B collect_pmlr.py --cache /data/paper-index/raw/http-cache \
--out /data/paper-index/collected --volume 267
# 待主库完成 arXiv 导入、且没有其他写入进程后,合并会议记录
python3 -B import_records.py --db /data/paper-index/papers.sqlite \
/data/paper-index/collected/pmlr-v267.jsonl
```
采集输出 JSONL 和对应 manifest,只有明确来源标签才产生相应决定:官方论文集记录为 accepted;PeerRead 使用其明确的 accepted 布尔标签,缺失标签保留 unknown。清单数目不一致时整个文件导入回滚,记录失败批次,不能标为覆盖完成。多个历史卷的全量覆盖仍需单独核验。旧卷可能把 PDF 放入归档;采集器对前 50 卷使用 Git partial clone 和 sparse checkout,仅取 `_posts` 和卷配置,不执行出版方项目代码。较新归档若超出 64 MiB 限额,也转为此方式。已对原本超限的 AISTATS 2009(第 5 卷)验证采集 84 条且工作区无 PDF。旧 YAML 的 `!binary` 文本、空文档尾标记会安全解析;带年份的 AISTATS/COLT 等历史 shortname 会规范化,避免名称别名漏届;缺失 year 时仅从官方会议 start 日期提取年份。`pmlr-volume-status.json` 逐卷保存结果和候选范围归属,`pmlr-last-run.json` 记录本批实际请求范围,局部重试不能证明全历史完成。缓存默认有效 24 小时;同一次批次可复用缓存,后续更新会重新读取动态入口。
PMLR 的同一出版 ID 可能对应多份 Git 元数据,例如 CoRL 2023 的两篇论文分别存在不同日期的副本。只有论文内容与标识完全一致、差异限于元数据日期时才合为一条,并保留源文件路径和日期版本;冲突副本会使该卷采集失败,保留上一份有效文件。manifest 分别记录 metadata_post_count、duplicate_post_count 和论文 count。
会议导入器还会独立检查每条记录的会议、年份、track 与 manifest 是否一致,并从实际记录计算缺失摘要数;旧 manifest 即使声称摘要完整,也不能让缺失摘要的来源被标为 complete。缺失的源摘要与合并后论文实体是否从其他来源补得摘要分别统计。
PeerRead 采集仅选择作者公开发布的 ICLR 2017 投稿元数据,固定到源仓库 commit。只依据明确的布尔 `accepted` 标签形成 accepted/rejected;空标签为 unknown,不使用其他数据分区中的 probably-rejected 推断。不会保存 reviews 或 parsed_pdfs 正文。
```sh
python3 -B collect_peerread.py --cache /data/paper-index/raw/http-cache \
# 采集与导入分开;这里只采集到独立文件
paper-atlas collect colm --cache /data/paper-index/raw/http-cache \
--out /data/paper-index/collected
```
数据任务以一次性 systemd 服务运行:`paper-atlas-arxiv-import`、`paper-atlas-pmlr-collect`、`paper-atlas-peerread-collect`;`paper-atlas-pmlr-verify` 用于全索引复核,`paper-atlas-conference-merge` 用于单写入合并。它们不是定时任务。主库只允许一个写入进程;会议采集先写独立 JSONL,待 arXiv 主批次结束后再合并;也可以使用 `merge_conferences.py` 在保存断点处暂停本项目的 arXiv unit、顺序合并会议清单并恢复。该操作应作为独立的一次性 systemd 任务运行,避免 SSH 断开影响恢复;不能同时启动多个合并任务。运行日志位于 `/data/paper-index/*-import.log`、`/data/paper-index/*-collect.log`。任何服务启动成功都不等于批次已完成,需检查批次状态、清单与完整性结果。标签按来源刷新使用 `paper_tags_source_evidence` 索引,避免每条论文都扫描整张标签表。运行中的 arXiv 可以通过同一协调脚本的 `--schema-only` 模式在断点更新索引后恢复;此模式要求已有 arXiv unit 正在运行。
`paper-atlas collect --help` 查看来源;`paper-atlas import --help` 查看导入方式。完整批次步骤见[使用指南](docs/usage.md)。
### 投稿事件身份与旧数据回填
## 数据如何组织
论文实体与投稿事件分开去重。同一篇论文可以在同一年向同一会议投稿多次,不能仅凭 paper_id、会议、标题相同就把投稿历史压成一次。跨来源只有原平台 forum ID、已验证的投稿编号,或正式发表稿的同届同 track 出版 DOI 等强证据才能关联同一事件。程序展示的内部编号仅在经过独立核验并带有 program_submission_number_verified 标记时才能用作投稿编号。
- **论文和投稿分开。** 一篇论文可以多次投稿;某次被拒、后来在其他会议录用,两段历史都保留。
- **决定必须有证据。** 评分、未出现在录用名单、没有公开决定,均不能推断为 Reject。未发现录用也不等于从未录用。
- **来源和标签保留出处。** 不同平台标签分开,自定义标签使用 `custom:*`;确认合并时保留源记录与审计。
- **覆盖范围逐届核对。** 主会、Findings、workshop 等分别记录。单个文件检查通过不能证明整个会议历年完整。
- **数据库只允许单写入。** 采集任务先写独立文件,核验后由协调器合并;原始数据和数据库均放在仓库外。
新版 ICLR manifest 的 parser_version 为 2,明确区别投稿编号、forum 和展示事件编号。旧版文件应先用缓存重新采集;事件证据回填只接受已入库、与 manifest 的会议/年份/track 和数量一致的文件。队列 JSON 包含 files 及对应的 file_sha256,文件变化后必须重新核验,不能继续使用旧哈希。
## 文档
```sh
python3 -B merge_conferences.py --db /data/paper-index/papers.sqlite \
--archive /data/paper-index/raw/arxiv-metadata.zip \
--backfill-queue /data/paper-index/collected/submission-evidence-queue.json
```
| 内容 | 入口 |
| --- | --- |
| 安装后怎么采集、导入和查询 | [使用指南](docs/usage.md) |
| 数据范围、各采集器和来源限制 | [数据源说明](docs/data-sources.md) |
| 论文、投稿、决定、标签及去重规则 | [数据模型](docs/data-model.md) |
| 批次恢复、更新和最终核验 | [运行维护](docs/operations.md) |
| 模块职责、开发和 CI | [开发说明](docs/development.md) |
协调器只维护本项目的 arXiv unit,并对维护过程加互斥锁。若 arXiv 正在运行,会保存断点、应用 schema、回填来源身份、导入本次附加文件、检查外键后恢复;若批次已经读至 EOF 并标记 complete,则不再启动它。若导入停下但未完成,会报错要求先调查,不以“unit 不存在”推断成功或重新全量运行。应以一次性 systemd 任务运行该命令,保持单写入。
## 验证与交付
首次升级会给已有投稿与决定补上来源关联;身份回填不重新导入标题摘要,不更改原抓取时间。确认同一次投稿后,可以合并镜像事件并保留所有决定及来源。当前稿件的原始强标识也会写入 source_records 元数据,后续源 URL 更新会同步保存。
CI 在已授权的远程 runner 上运行测试、构建 wheel,并从源码目录外验证安装后的命令及随包资源。数据库全量去重和覆盖率是独立的数据验收步骤,不因代码测试通过而自动完成。
通用 JSONL 导入支持 decision_events 数组及 observed_at、decision_date、submitted_at。没有取得明确决定时,可用 unknown 状态和空数组仅保存投稿,不生成虚假的决定;`--status unknown` 也能查询这些投稿。历史快照使用其实际观察时间,不能因今天导入就覆盖较新来源的状态;同一观察时间内按原始决定日期排序。反复导入相同证据只补来源关联,不重复制造决定事件。全量结束后仍须单独核验重复候选、冲突决定及来源覆盖,迁移检查不能代替最终全库审计。
### 固定版本的补充元数据快照
`download_snapshot.py` 流式下载到 `.part`,核对完整 HTTP 响应、预期大小和可信来源发布的校验和后,才发布正式文件及 manifest。已存在的正式文件会先重新核验;错误校验和不会覆盖它。默认保留 8 GiB 空闲空间,同一输出只允许一个下载进程。
DBLP 官方月度快照用于补充出版目录和历史届次,元数据按 CC0 发布。固定版本可复现,不把每日变动下载链接当作同一快照:
```sh
python3 -B download_snapshot.py \
--url https://drops.dagstuhl.de/storage/artifacts/dblp/xml/2026/dblp-2026-11-01.xml.gz \
--md5 18ad5db59592406ec74289975c4b44cb \
--output /data/paper-index/raw/dblp-2026-11-01.xml.gz
```
[官方版本与校验值](https://doi.org/10.4230/dblp.xml.2026-11-01)。下载器另外计算 SHA-256,记录来源 URL、大小和核验时间。DBLP 没有提供的摘要不能编造,出版目录也不能作为拒稿证据。
DBLP 目录盘点先核验固定快照与[官方 DTD](https://doi.org/10.4230/dblp.xml.dtd.2023-06-28),再流式读取 XML,直至 gzip EOF 检查通过:
```sh
python3 -B download_snapshot.py \
--url https://drops.dagstuhl.de/storage/artifacts/dblp/xml/2023/dblp-2023-06-28.dtd \
--md5 96e4283e71886dec2173718957293648 \
--output /data/paper-index/raw/dblp-2023-06-28.dtd
python3 -B catalogue_dblp.py \
--archive /data/paper-index/raw/dblp-2026-11-01.xml.gz \
--dtd /data/paper-index/raw/dblp-2023-06-28.dtd \
--out /data/paper-index/collected/dblp
```
`dblp-catalogue.sqlite` 保存论文集目录、按 crossref/年份/booktitle 的记录数量、各目录族数量及少量样本;`dblp-ai-candidates.jsonl` 保存候选会议族的原始索引元数据。存在多个出版卷字段的记录另存 `volume_anomalies`,保留全部值供归属核验;统计中仍只计一条,不任取第一个字段或展开成多篇。候选范围包含历史名称与 workshop,尚未赋予主会归属或录用状态。后续须依据具体论文集和届次核对目标范围、track 与出版标识,才能生成入库文件;不会把同一目录下的所有记录都视为主会。该盘点不下载 PDF,也不更改论文主库。
XML 只允许校验过的本地 DTD;官方通用名 `dblp.dtd` 与月度快照使用的 `dblp-2023-06-28.dtd` 均解析到同一份已校验文件。拒绝其他外部实体和内部 DTD 子集,不访问 XML 声明的任意网络地址,并遵循源 XML 的字符编码。临时目录库或 gzip 检查失败时不发布正式候选文件,需先调查残留的 `.part` 状态,再决定是否重新运行。
公开 ICLR 投稿快照的固定 Release 供后续筛选投稿和明确决定;它是第三方保存的 OpenReview 历史副本,不等于最新官方全量数据:
```sh
python3 -B download_snapshot.py \
--url https://github.com/qhjqhj00/iclr-openreview-reviews/releases/download/v1.0/iclr.tar.bz2 \
--sha256 350071daf25ebf1aef854ee1d5dc3a81834e1c468917c82a4f78af5548de34fc \
--expected-bytes 512940722 \
--output /data/paper-index/raw/iclr-openreview-v1.0.tar.bz2
```
[快照来源与复现脚本](https://github.com/qhjqhj00/iclr-openreview-reviews)。原包包含评审回复;解析只筛选公开投稿的索引、标题、摘要、平台标签及带证据的明确决定,不提取评审正文、作者邮件或论文全文。必须核对原始 invitation、readers、签名及决定字段,不能根据评分或录用名单缺席推断拒稿。
优先处理近期三届,再单独补全 2020–2023;不以 2017 年的 PeerRead 样本代表近期拒稿覆盖:
```sh
python3 -B collect_openreview_snapshot.py \
--archive /data/paper-index/raw/iclr-openreview-v1.0.tar.bz2 \
--out /data/paper-index/collected --year 2024 --year 2025 --year 2026
```
解析器先核验固定 SHA-256,再流式读取 tar,以临时 SQLite 按年份和 forum 关联投稿及决定,兼容回复先于投稿的归档顺序。只接受明确对 everyone 公开的投稿和具有会议主席签名的 Decision;保留原始决定值、note ID、决定日期、invitation 和签名证据。无明确决定的投稿保留 unknown,不产生拒稿推断。无法关联到公开投稿元数据的公开决定另存 unmatched-decisions 文件,供复核,不擅自创建论文。输出清单分别统计投稿状态、决定事件、缺失摘要和排除原因;重复副本去重,内容冲突会中止。导入时若同一编号指向不同 forum,或同一 forum 指向不同投稿编号,会报冲突并回滚,不强制合并。
该版本是 2026-05-08 发布的第三方历史副本,不代表平台当前状态或全部投稿;缺失决定及公开性筛选均需核验。采集成功也不等于主库已更新,后续应通过单写入协调器导入,并核对同届官方程序与公开决定的差异。
入库前按真实 forum 对照同届官方程序,生成逐年状态、名单缺口、历史决定差异和文本版本差异报告,以及带 SHA-256 的现有程序身份回填队列:
```sh
python3 -B audit_iclr_snapshot.py --root /data/paper-index/collected \
--year 2024 --year 2025 --year 2026 \
--report /data/paper-index/collected/iclr-recent-audit.json
```
该检查要求清单和记录的届次、数量、摘要缺失统计一致,拒绝重复 forum、编号冲突及缺失公开决定证据。报告中的名单差异和 unknown 不会转换成拒稿。不同时间来源的决定保留各自证据,强 forum 标识负责关联同一次投稿;报告通过不等于平台全量覆盖或最终全库去重已经完成。生成的 `*-backfill-queue.json` 仅用于已入库程序记录的身份回填,快照文件仍须由协调器单独导入。
下载异常后先检查对应进程或 CI 任务确已终止,再使用同一参数增加 `--resume`。只有断点身份一致且服务器实际返回正确 Range 响应才续传;不支持时保留断点并报错,不自动重下。正式文件与下载清单均放在仓库外,不提交大型来源包。
### COLM 与 NeurIPS 官方摘要
```sh
python3 -B collect_proceedings.py --source colm \
--cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
python3 -B collect_proceedings.py --source neurips \
--cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
```
公共元数据请求对连接重置、超时及不完整响应最多尝试三次,保留原有有效缓存;HTTP 403 等访问拒绝会直接记录失败。声明 Content-Length 与实际响应不符时不发布缓存,已有缓存也受字节上限约束。旧页面没有赋值的 HTML 属性按空值处理,避免某一个空 class 属性阻断整届采集。
采集器从官方根目录发现实际发表届次,仅访问索引和 Abstract HTML 页面,不请求 PDF、补充材料或源码。可用重复 `--year` 限定实际存在的届次;不存在的年份会报错,局部重试保留其他届次的已有状态。每个来源串行请求,实际网络响应后等待 1 秒;缓存命中不额外等待。
COLM 当前目录提供 2024–2026 三届 pre-proceedings,按会议年份保存,保留平台实际出版日期和出版状态,不把晚补刊日期当作会议年份。NeurIPS 当前目录提供 1987–2025,并包含 2025 独立卷及 2021 独立 Datasets and Benchmarks 索引。主会、datasets_benchmarks、position、creative_ai 分开出文件,不混为同一个 track。跨平台自标识仅从出版方 paper-actions 区域链接与 citation 元数据提取,不把摘要里的引用链接当作当前论文 ID。manifest 的 `parser_version` 至少为 2 才允许入库;旧文件须利用缓存重新采集,避免错误身份合并。首次真实样本核对:COLM 2024 官方目录 299 个摘要入口;NeurIPS 1987 为 90 个,作者与摘要均已验证解析。样本成功不代表所有页面采集完成。
`colm-index-status.json`、`neurips-index-status.json` 保存逐索引状态;对应 `*-last-run.json` 保存本次实际选择范围。每个 JSONL 的 manifest 记录索引入口数、track 记录数和缺失摘要数。论文数量与官方页面声明不一致会失败,缺失摘要会标记 partial;不会把页脚、姓名政策或其他正文当作摘要。
这两个采集器只写采集文件,合并仍通过单写入协调流程。全量历史覆盖和所有未实现会议仍待最终核验。
### ACL Anthology 权威 XML
```sh
python3 -B collect_acl.py \
--checkout /data/paper-index/raw/metadata-checkouts/acl-anthology \
--out /data/paper-index/collected
```
`metadata_checkout.py` 对官方仓库做只包含 `data/xml` 的 partial clone/sparse checkout,不获取 PDF,不执行仓库 hooks 或构建。每批固定到实际 Git commit。遍历 XML 的 volume venue 字段选择 ACL、EMNLP、NAACL、EACL、COLING、CoNLL;Findings 按所属会议独立成 track,不按文件名把其他同场 workshop 冒充主会。
新旧 Anthology ID 按官方规则构造,并核对源 DOI。保留 C69、D19-50 至 D19-66、W 系列的历史编号例外,排除编号 0 的前言。XML 混合标记通过 itertext 提取;无摘要保留为空并标记 partial。主会、Findings、workshop、demo、tutorial、industry、shared_task 分开。
联合举办会议保存一个会议事件及多个名称别名。例如 LREC-COLING 的论文可用 COLING 或 LREC 查询,仍只有一次该届投稿记录。可用 `--track main` 限定主会,避免把 Findings 或演示算入主会;别名数据在正常单写入合并时应用 schema。
`acl-collection-status.json` 记录每个 XML 及其 volume 的处理结果和排除原因;`acl-last-run.json` 记录源 commit 与本批范围。`--collection P17` 可限定一个确实存在的 collection。已验证真实 ACL 2017 长文 195 条、短文 107 条,二者摘要完整;其他 track 按源实际缺失情况记录,不能凭样本宣称整个历史完成。
### ICLR 官方公开程序元数据
```sh
python3 -B collect_iclr.py --cache /data/paper-index/raw/http-cache \
--out /data/paper-index/collected
# 入库后按官方主题检索;主题值以实际来源为准
python3 -B query.py --db /data/paper-index/papers.sqlite \
--venue ICLR --tag 'iclr_program:topic=Reinforcement Learning->Deep RL'
```
从 ICLR 官方年份导航登记届次,读取对应 Papers 页面明确引用的 orals-posters/abstracts JSON。核对声明数量、分页状态及唯一 event ID;只下载公开程序元数据,不访问用户资料、PDF、海报、幻灯片或视频。`--year` 可限制年份;清单中的已采集数与数据库已入库数分别统计。
官方程序中的 event 数不是论文数:同一篇文章可以有 poster 和 oral 两场展示。程序还会给负 sourceid 的 oral 展示生成占位 OpenReview 链接,这些链接不能作为真实论文标识。采集器核对原投稿编号、标题与作者后合并展示,优先使用正编号记录的真实论文链接;原展示编号和类型保留在源元数据中。
同一投稿编号的展示标题可能有小幅修订。只有负编号 oral 与正编号原稿对应、至少两位作者完全一致且标题相似度满足严格阈值时,才保留为同稿的标题变体;该规则仅在一届官方程序内部生效,不用于跨平台模糊合并。
只将 Conference 来源的论文视为 ICLR 主会录用,排除 Blog 和期刊转会展示,并在审计清单保存排除计数。2020/2021 旧数据允许依据官方主会 paper program 形成录用证据;无 forum ID 时保留 program 身份和匹配候选,不伪造标识。历史年度返回 404 会登记 unavailable,未来未发表届次登记 not_yet_published,其他访问或解析失败登记 blocked;这些状态都不能宣称该年已覆盖。
主题、关键词使用 `iclr_program:topic`、`iclr_program:keyword` 命名空间。通用导入器按源记录刷新标签证据,保留同一论文其他源记录及 `custom:*` 标签。每年的 JSONL、manifest、`iclr-program-year-status.json` 和 `iclr-program-last-run.json` 保存实际范围、独立展示数、主会展示数、论文数、缺失摘要与缺失 forum 数。录用程序不能提供拒稿全集,公开投稿及拒稿补充仍需独立来源。
### CVF / ECVA 视觉会议元数据
```sh
python3 -B collect_vision.py --source cvf \
--cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
python3 -B collect_vision.py --source ecva \
--cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
```
CVF 从官方根目录发现 CVPR、ICCV 主会与 Findings,分别保存 track;不把 workshop 或 WACV 算入这些会议。优先跟随页面明确提供的 All Papers 链接,避免只收录默认显示的第一天。ECVA 从官方总目录按论文 URL 的会议与年份分组,当前总目录列有 ECCV 2018、2020、2022、2024。不存在于来源的 `--venue`、`--year` 或二者组合会报错,不补造双年会议届次,也不能因此推断旧届次未举办。
只访问论文索引与摘要 HTML/PHP 页面。作者优先取 citation 元数据,旧页面尊重声明编码;无 citation 作者时取索引中的作者行,排除论文集出版信息。DOI 只取该论文条目所属的 DOI 链接或 citation 字段;冲突会失败,不从摘要引用中抽取当前论文 ID。不请求 PDF、补充材料、视频或代码。
每份清单记录索引入口数、论文数、缺失摘要与作者数,缺失内容保留为空并标记 partial。存在未被标题条目解析的论文链接时会失败,防止静默漏项。逐来源的 discovery、index-status、last-run 文件分别保存发现目录、逐届结果和实际请求范围。可重复使用 `--year` 或 `--venue` 限定重试;其他届次状态会保留。官方开放目录之外的早期届次、尚未刊出的新届次和独立出版方历史数据仍需后续补齐,采集器完成不代表全历史覆盖已完成。
## 长期维护与更新
1. 每次采集先核对来源、许可、可访问性、空闲空间和现有运行批次。保持低并发,遵守来源限流、重试与人机验证要求。
2. 下载先写 `.part`,确认完整 HTTP 响应和 ZIP 目录,再记录 SHA-256 与时间后切换为正式文件。正式导入会读至 EOF,从而验证成员 CRC。
3. 每次导入保留来源证据和批次,按会议/年份/track 报告覆盖状态。缺失摘要留空,不编造文本;缺失决定保留未知。
4. 核验行数、唯一标识、外键、全文查询和 SQLite 完整性后,再将批次视为交付。样本通过不能证明全量正确。
5. 数据库备份使用 SQLite backup API 或一致性快照,不能在写入过程中只复制 `.sqlite` 主文件而遗漏 WAL。按明确保留策略轮换备份,避免无上限积累。
6. 不为每次数据更新生成新 Docker 镜像;不常驻下载器;不自动创建定时任务。本项目的更新任务应按需或经用户授权调度。
7. 代码通过工作分支和 PR 交付;构建或 CI 只能使用用户确认的远程 runner,不在本机触发构建。
## 当前访问限制
2026-11-06 从目标服务器测试:Kaggle 元数据 GET 可下载;OpenReview 会议 group 接口可读,但 notes 接口返回 `ChallengeRequiredError`。拒稿记录导入尚受此限制,不能用录用名单替代全部投稿,也不能声称已覆盖全部拒稿。需通过平台支持的访问方式或有合法来源的公开数据导出继续补全。
已核验的采集文件计数:ICLR 2020–2026 主会 15,534 条、COLM 2024–2026 共 1,573 条,摘要均已取得;候选范围内的 PMLR 文件为 31,428 条,已剔除两份重复出版元数据。ACL Anthology 已选文件为 63,297 条,其中 15,147 条源记录缺摘要。这些是采集文件的来源记录数,不能相加当作去重论文数,也不能代替主库实时统计;实际入库量使用 query.py --stats 查看。
代码在 `codex/metadata-index` 工作分支交付,[Draft PR #1](https://gitea.entroia.com/Zayden-Jung/paper-atlas/pulls/1) 尚未合并,等待全量就绪后用户手动合并。各提交分别由远程 CI 验证,结果以对应提交的 Gitea Actions 检查为准。全量导入、其他会议适配器、去重复核和覆盖率核验继续进行,应随实际结果更新此说明。
开发通过工作分支和 PR 交付,由用户手动合并。
+21
View File
@@ -0,0 +1,21 @@
# 数据模型与身份解析
| 表 | 用途 |
| --- | --- |
| `papers` / `papers_fts` | 稳定论文实体、代表性标题摘要、FTS5 全文检索 |
| `identifiers` | arXiv ID、DOI 等唯一外部标识 |
| `source_records` / `sources` | 平台记录、原平台标题摘要与索引元数据、来源 URL 和抓取时间 |
| `venues` / `venue_aliases` / `editions` | 会议或期刊系列、年份与 track,区分主会、Findings 和 workshop,联合会议用别名查询,不重复生成事件 |
| `edition_sources` | 每届每个来源的覆盖状态、预计/已导入数量、错误与断点 |
| `submissions` / `submission_identifiers` / `submission_sources` | 投稿事件、按届次限定的原平台强标识、多来源证据;同稿的不同投稿尝试分别保留 |
| `decisions` / `decision_sources` | 明确决定、原始决定日期、来源观察日期及证据,不把缺失决定编造成拒稿 |
| `submission_merges` / `decision_merges` | 投稿事件与重复决定观察的合并审计,保留来源关联并避免重用已合并的编号 |
| `tags` / `paper_tags` | 平台标签与人工标签,使用命名空间隔离 |
| `match_candidates` / `paper_merges` | 潜在重复复核状态、确定合并审计 |
| `ingest_runs` | 导入批次、状态、错误和恢复位置 |
决定状态为 `accepted`、`rejected`、`desk_rejected`、`withdrawn`、`under_review`、`unknown`。一篇论文可以对应多次投稿和多个决定,不能用单一“最终会议”字段覆盖历史。查询中按观察时间选择最近状态;后续分析需要同时检查来源覆盖率。
当前确定性去重使用 arXiv ID、单个 DOI 和 OpenReview forum ID;跨平台同标题还需至少两位作者吻合,或作者吻合且摘要相同,才自动合并。保留各平台源记录,避免合并后丢失出处。规范化标题只建检索索引,尚未实现模糊匹配流程;**现阶段不保证跨平台去重已经完成**。不同平台标签不自动等同,自定义标签应使用 `custom:*` 命名空间。
+223
View File
@@ -0,0 +1,223 @@
# 数据范围与采集器
## 数据范围与来源
- arXiv:官方推荐的 Cornell/Kaggle 元数据快照,包含标识、作者、标题、摘要、类别、版本与外部索引。默认导入整个快照,通过类别筛选 AI 论文,避免永久丢弃交叉领域记录。
- 会议论文集:优先官方来源,计划接入 OpenReview、PMLR、ACL Anthology、NeurIPS、CVF、ECVA、AAAI、IJCAI;DBLP 用于补充会议目录、标识与出版信息。GitHub 聚合名单只作发现线索,不能作为未经核验的决定证据。
- 投稿历史:只收录公开可访问并有证据的记录;没有录用信息不等于拒稿,不能据此推断“从未中会”。
- 当前不下载 PDF、LaTeX、图片或补充材料,不提取论文全文,不推断匿名作者身份。
官方入口:
- [arXiv bulk metadata](https://info.arxiv.org/help/bulk_data/index.html)
- [arXiv metadata snapshot](https://www.kaggle.com/datasets/Cornell-University/arxiv)
- [OpenReview 数据接口说明](https://docs.openreview.net/how-to-guides/data-retrieval-and-modification/how-to-get-all-notes-for-submissions-reviews-rebuttals-etc)
- [PMLR](https://proceedings.mlr.press/)
- [ACL Anthology 权威 XML 与 API](https://aclanthology.org/info/development/)
- [NeurIPS](https://proceedings.neurips.cc/)
- [COLM](https://proceedings.colm.cc/)
- [CVF](https://openaccess.thecvf.com/)
- [ECVA](https://www.ecva.net/papers.php)
- [IJCAI](https://www.ijcai.org/all_proceedings/)
- [AAAI](https://ojs.aaai.org/index.php/AAAI/issue/archive)
- [DBLP](https://dblp.org/db/conf/)
## 候选会议覆盖目录
| 领域 | 会议系列 | 主要发现入口 |
| --- | --- | --- |
| 机器学习、理论 | NeurIPS、ICML、ICLR、COLM、COLT、AISTATS、UAI、CLeaR | OpenReview、PMLR、NeurIPS、COLM 官方论文集 |
| 自然语言 | ACL、EMNLP、NAACL、EACL、COLING、CoNLL | ACL Anthology |
| 视觉 | CVPR、ICCV、ECCV | CVF、ECVA |
| 综合 AI | AAAI、IJCAI、ECAI | 官方论文集、DBLP |
| 检索与数据 | KDD、SIGIR、WWW、WSDM、RecSys、CIKM、ICDM、SDM、ECML-PKDD | 官方出版方、DBLP,具体年份入口待逐项登记 |
| 机器人 | ICRA、IROS、RSS、CoRL | 官方论文集、PMLR、DBLP |
| 规划、知识与智能体 | ICAPS、KR、AAMAS | 官方论文集、DBLP |
| 语音与音频 | ICASSP、INTERSPEECH、ISMIR | 官方论文集、ISCA、DBLP |
| 相关专门领域 | MICCAI、FAccT | 官方出版方、DBLP |
这些是领域覆盖候选,不构成统一排名,也不能据此声称已全部入库。逐年目录以真实举办届次和正式出版清单为准:双年会议不补造不存在的年份;主会、Findings、workshop 分开。`edition_sources` 保存实际采集状态,不能用候选名单代替覆盖率报告。
## 官方会议元数据采集
```sh
# 单卷示例:ICML 2025,读取官方 gh-pages 元数据,不下载论文正文
python3 -B -m paper_atlas collect pmlr --cache /data/paper-index/raw/http-cache \
--out /data/paper-index/collected --volume 267
# 待主库完成 arXiv 导入、且没有其他写入进程后,合并会议记录
python3 -B -m paper_atlas import records --db /data/paper-index/papers.sqlite \
/data/paper-index/collected/pmlr-v267.jsonl
```
采集输出 JSONL 和对应 manifest,只有明确来源标签才产生相应决定:官方论文集记录为 accepted;PeerRead 使用其明确的 accepted 布尔标签,缺失标签保留 unknown。清单数目不一致时整个文件导入回滚,记录失败批次,不能标为覆盖完成。多个历史卷的全量覆盖仍需单独核验。旧卷可能把 PDF 放入归档;采集器对前 50 卷使用 Git partial clone 和 sparse checkout,仅取 `_posts` 和卷配置,不执行出版方项目代码。较新归档若超出 64 MiB 限额,也转为此方式。已对原本超限的 AISTATS 2009(第 5 卷)验证采集 84 条且工作区无 PDF。旧 YAML 的 `!binary` 文本、空文档尾标记会安全解析;带年份的 AISTATS/COLT 等历史 shortname 会规范化,避免名称别名漏届;缺失 year 时仅从官方会议 start 日期提取年份。`pmlr-volume-status.json` 逐卷保存结果和候选范围归属,`pmlr-last-run.json` 记录本批实际请求范围,局部重试不能证明全历史完成。缓存默认有效 24 小时;同一次批次可复用缓存,后续更新会重新读取动态入口。
PMLR 的同一出版 ID 可能对应多份 Git 元数据,例如 CoRL 2023 的两篇论文分别存在不同日期的副本。只有论文内容与标识完全一致、差异限于元数据日期时才合为一条,并保留源文件路径和日期版本;冲突副本会使该卷采集失败,保留上一份有效文件。manifest 分别记录 metadata_post_count、duplicate_post_count 和论文 count。
会议导入器还会独立检查每条记录的会议、年份、track 与 manifest 是否一致,并从实际记录计算缺失摘要数;旧 manifest 即使声称摘要完整,也不能让缺失摘要的来源被标为 complete。缺失的源摘要与合并后论文实体是否从其他来源补得摘要分别统计。
PeerRead 采集仅选择作者公开发布的 ICLR 2017 投稿元数据,固定到源仓库 commit。只依据明确的布尔 `accepted` 标签形成 accepted/rejected;空标签为 unknown,不使用其他数据分区中的 probably-rejected 推断。不会保存 reviews 或 parsed_pdfs 正文。
```sh
python3 -B -m paper_atlas collect peerread --cache /data/paper-index/raw/http-cache \
--out /data/paper-index/collected
```
数据任务以一次性 systemd 服务运行:`paper-atlas-arxiv-import`、`paper-atlas-pmlr-collect`、`paper-atlas-peerread-collect`;`paper-atlas-pmlr-verify` 用于全索引复核,`paper-atlas-conference-merge` 用于单写入合并。它们不是定时任务。主库只允许一个写入进程;会议采集先写独立 JSONL,待 arXiv 主批次结束后再合并;也可以使用 `paper-atlas maintain merge` 在保存断点处暂停本项目的 arXiv unit、顺序合并会议清单并恢复。该操作应作为独立的一次性 systemd 任务运行,避免 SSH 断开影响恢复;不能同时启动多个合并任务。运行日志位于 `/data/paper-index/*-import.log`、`/data/paper-index/*-collect.log`。任何服务启动成功都不等于批次已完成,需检查批次状态、清单与完整性结果。标签按来源刷新使用 `paper_tags_source_evidence` 索引,避免每条论文都扫描整张标签表。运行中的 arXiv 可以通过同一协调脚本的 `--schema-only` 模式在断点更新索引后恢复;此模式要求已有 arXiv unit 正在运行。
### 投稿事件身份与旧数据回填
论文实体与投稿事件分开去重。同一篇论文可以在同一年向同一会议投稿多次,不能仅凭 paper_id、会议、标题相同就把投稿历史压成一次。跨来源只有原平台 forum ID、已验证的投稿编号,或正式发表稿的同届同 track 出版 DOI 等强证据才能关联同一事件。程序展示的内部编号仅在经过独立核验并带有 program_submission_number_verified 标记时才能用作投稿编号。
新版 ICLR manifest 的 parser_version 为 2,明确区别投稿编号、forum 和展示事件编号。旧版文件应先用缓存重新采集;事件证据回填只接受已入库、与 manifest 的会议/年份/track 和数量一致的文件。队列 JSON 包含 files 及对应的 file_sha256,文件变化后必须重新核验,不能继续使用旧哈希。
```sh
python3 -B -m paper_atlas maintain merge --db /data/paper-index/papers.sqlite \
--archive /data/paper-index/raw/arxiv-metadata.zip \
--backfill-queue /data/paper-index/collected/submission-evidence-queue.json
```
协调器只维护本项目的 arXiv unit,并对维护过程加互斥锁。若 arXiv 正在运行,会保存断点、应用 schema、回填来源身份、导入本次附加文件、检查外键后恢复;若批次已经读至 EOF 并标记 complete,则不再启动它。若导入停下但未完成,会报错要求先调查,不以“unit 不存在”推断成功或重新全量运行。应以一次性 systemd 任务运行该命令,保持单写入。
首次升级会给已有投稿与决定补上来源关联;身份回填不重新导入标题摘要,不更改原抓取时间。确认同一次投稿后,可以合并镜像事件并保留所有决定及来源。当前稿件的原始强标识也会写入 source_records 元数据,后续源 URL 更新会同步保存。
通用 JSONL 导入支持 decision_events 数组及 observed_at、decision_date、submitted_at。没有取得明确决定时,可用 unknown 状态和空数组仅保存投稿,不生成虚假的决定;`--status unknown` 也能查询这些投稿。历史快照使用其实际观察时间,不能因今天导入就覆盖较新来源的状态;同一观察时间内按原始决定日期排序。反复导入相同证据只补来源关联,不重复制造决定事件。全量结束后仍须单独核验重复候选、冲突决定及来源覆盖,迁移检查不能代替最终全库审计。
### 固定版本的补充元数据快照
`paper-atlas download` 流式下载到 `.part`,核对完整 HTTP 响应、预期大小和可信来源发布的校验和后,才发布正式文件及 manifest。已存在的正式文件会先重新核验;错误校验和不会覆盖它。默认保留 8 GiB 空闲空间,同一输出只允许一个下载进程。
DBLP 官方月度快照用于补充出版目录和历史届次,元数据按 CC0 发布。固定版本可复现,不把每日变动下载链接当作同一快照:
```sh
python3 -B -m paper_atlas download \
--url https://drops.dagstuhl.de/storage/artifacts/dblp/xml/2026/dblp-2026-11-01.xml.gz \
--md5 18ad5db59592406ec74289975c4b44cb \
--output /data/paper-index/raw/dblp-2026-11-01.xml.gz
```
[官方版本与校验值](https://doi.org/10.4230/dblp.xml.2026-11-01)。下载器另外计算 SHA-256,记录来源 URL、大小和核验时间。DBLP 没有提供的摘要不能编造,出版目录也不能作为拒稿证据。
DBLP 目录盘点先核验固定快照与[官方 DTD](https://doi.org/10.4230/dblp.xml.dtd.2023-06-28),再流式读取 XML,直至 gzip EOF 检查通过:
```sh
python3 -B -m paper_atlas download \
--url https://drops.dagstuhl.de/storage/artifacts/dblp/xml/2023/dblp-2023-06-28.dtd \
--md5 96e4283e71886dec2173718957293648 \
--output /data/paper-index/raw/dblp-2023-06-28.dtd
python3 -B -m paper_atlas catalogue dblp \
--archive /data/paper-index/raw/dblp-2026-11-01.xml.gz \
--dtd /data/paper-index/raw/dblp-2023-06-28.dtd \
--out /data/paper-index/collected/dblp
```
`dblp-catalogue.sqlite` 保存论文集目录、按 crossref/年份/booktitle 的记录数量、各目录族数量及少量样本;`dblp-ai-candidates.jsonl` 保存候选会议族的原始索引元数据。存在多个出版卷字段的记录另存 `volume_anomalies`,保留全部值供归属核验;统计中仍只计一条,不任取第一个字段或展开成多篇。候选范围包含历史名称与 workshop,尚未赋予主会归属或录用状态。后续须依据具体论文集和届次核对目标范围、track 与出版标识,才能生成入库文件;不会把同一目录下的所有记录都视为主会。该盘点不下载 PDF,也不更改论文主库。
XML 只允许校验过的本地 DTD;官方通用名 `dblp.dtd` 与月度快照使用的 `dblp-2023-06-28.dtd` 均解析到同一份已校验文件。拒绝其他外部实体和内部 DTD 子集,不访问 XML 声明的任意网络地址,并遵循源 XML 的字符编码。临时目录库或 gzip 检查失败时不发布正式候选文件,需先调查残留的 `.part` 状态,再决定是否重新运行。
公开 ICLR 投稿快照的固定 Release 供后续筛选投稿和明确决定;它是第三方保存的 OpenReview 历史副本,不等于最新官方全量数据:
```sh
python3 -B -m paper_atlas download \
--url https://github.com/qhjqhj00/iclr-openreview-reviews/releases/download/v1.0/iclr.tar.bz2 \
--sha256 350071daf25ebf1aef854ee1d5dc3a81834e1c468917c82a4f78af5548de34fc \
--expected-bytes 512940722 \
--output /data/paper-index/raw/iclr-openreview-v1.0.tar.bz2
```
[快照来源与复现脚本](https://github.com/qhjqhj00/iclr-openreview-reviews)。原包包含评审回复;解析只筛选公开投稿的索引、标题、摘要、平台标签及带证据的明确决定,不提取评审正文、作者邮件或论文全文。必须核对原始 invitation、readers、签名及决定字段,不能根据评分或录用名单缺席推断拒稿。
优先处理近期三届,再单独补全 2020–2023;不以 2017 年的 PeerRead 样本代表近期拒稿覆盖:
```sh
python3 -B -m paper_atlas collect iclr-snapshot \
--archive /data/paper-index/raw/iclr-openreview-v1.0.tar.bz2 \
--out /data/paper-index/collected --year 2024 --year 2025 --year 2026
```
解析器先核验固定 SHA-256,再流式读取 tar,以临时 SQLite 按年份和 forum 关联投稿及决定,兼容回复先于投稿的归档顺序。只接受明确对 everyone 公开的投稿和具有会议主席签名的 Decision;保留原始决定值、note ID、决定日期、invitation 和签名证据。无明确决定的投稿保留 unknown,不产生拒稿推断。无法关联到公开投稿元数据的公开决定另存 unmatched-decisions 文件,供复核,不擅自创建论文。输出清单分别统计投稿状态、决定事件、缺失摘要和排除原因;重复副本去重,内容冲突会中止。导入时若同一编号指向不同 forum,或同一 forum 指向不同投稿编号,会报冲突并回滚,不强制合并。
该版本是 2026-05-08 发布的第三方历史副本,不代表平台当前状态或全部投稿;缺失决定及公开性筛选均需核验。采集成功也不等于主库已更新,后续应通过单写入协调器导入,并核对同届官方程序与公开决定的差异。
入库前按真实 forum 对照同届官方程序,生成逐年状态、名单缺口、历史决定差异和文本版本差异报告,以及带 SHA-256 的现有程序身份回填队列:
```sh
python3 -B -m paper_atlas audit iclr --root /data/paper-index/collected \
--year 2024 --year 2025 --year 2026 \
--report /data/paper-index/collected/iclr-recent-audit.json
```
该检查要求清单和记录的届次、数量、摘要缺失统计一致,拒绝重复 forum、编号冲突及缺失公开决定证据。报告中的名单差异和 unknown 不会转换成拒稿。不同时间来源的决定保留各自证据,强 forum 标识负责关联同一次投稿;报告通过不等于平台全量覆盖或最终全库去重已经完成。生成的 `*-backfill-queue.json` 仅用于已入库程序记录的身份回填,快照文件仍须由协调器单独导入。
下载异常后先检查对应进程或 CI 任务确已终止,再使用同一参数增加 `--resume`。只有断点身份一致且服务器实际返回正确 Range 响应才续传;不支持时保留断点并报错,不自动重下。正式文件与下载清单均放在仓库外,不提交大型来源包。
### COLM 与 NeurIPS 官方摘要
```sh
python3 -B -m paper_atlas collect colm \
--cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
python3 -B -m paper_atlas collect neurips \
--cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
```
公共元数据请求对连接重置、超时及不完整响应最多尝试三次,保留原有有效缓存;HTTP 403 等访问拒绝会直接记录失败。声明 Content-Length 与实际响应不符时不发布缓存,已有缓存也受字节上限约束。旧页面没有赋值的 HTML 属性按空值处理,避免某一个空 class 属性阻断整届采集。
采集器从官方根目录发现实际发表届次,仅访问索引和 Abstract HTML 页面,不请求 PDF、补充材料或源码。可用重复 `--year` 限定实际存在的届次;不存在的年份会报错,局部重试保留其他届次的已有状态。每个来源串行请求,实际网络响应后等待 1 秒;缓存命中不额外等待。
COLM 当前目录提供 2024–2026 三届 pre-proceedings,按会议年份保存,保留平台实际出版日期和出版状态,不把晚补刊日期当作会议年份。NeurIPS 当前目录提供 1987–2025,并包含 2025 独立卷及 2021 独立 Datasets and Benchmarks 索引。主会、datasets_benchmarks、position、creative_ai 分开出文件,不混为同一个 track。跨平台自标识仅从出版方 paper-actions 区域链接与 citation 元数据提取,不把摘要里的引用链接当作当前论文 ID。manifest 的 `parser_version` 至少为 2 才允许入库;旧文件须利用缓存重新采集,避免错误身份合并。首次真实样本核对:COLM 2024 官方目录 299 个摘要入口;NeurIPS 1987 为 90 个,作者与摘要均已验证解析。样本成功不代表所有页面采集完成。
`colm-index-status.json`、`neurips-index-status.json` 保存逐索引状态;对应 `*-last-run.json` 保存本次实际选择范围。每个 JSONL 的 manifest 记录索引入口数、track 记录数和缺失摘要数。论文数量与官方页面声明不一致会失败,缺失摘要会标记 partial;不会把页脚、姓名政策或其他正文当作摘要。
这两个采集器只写采集文件,合并仍通过单写入协调流程。全量历史覆盖和所有未实现会议仍待最终核验。
### ACL Anthology 权威 XML
```sh
python3 -B -m paper_atlas collect acl \
--checkout /data/paper-index/raw/metadata-checkouts/acl-anthology \
--out /data/paper-index/collected
```
`paper_atlas/io/git_metadata.py` 对官方仓库做只包含 `data/xml` 的 partial clone/sparse checkout,不获取 PDF,不执行仓库 hooks 或构建。每批固定到实际 Git commit。遍历 XML 的 volume venue 字段选择 ACL、EMNLP、NAACL、EACL、COLING、CoNLL;Findings 按所属会议独立成 track,不按文件名把其他同场 workshop 冒充主会。
新旧 Anthology ID 按官方规则构造,并核对源 DOI。保留 C69、D19-50 至 D19-66、W 系列的历史编号例外,排除编号 0 的前言。XML 混合标记通过 itertext 提取;无摘要保留为空并标记 partial。主会、Findings、workshop、demo、tutorial、industry、shared_task 分开。
联合举办会议保存一个会议事件及多个名称别名。例如 LREC-COLING 的论文可用 COLING 或 LREC 查询,仍只有一次该届投稿记录。可用 `--track main` 限定主会,避免把 Findings 或演示算入主会;别名数据在正常单写入合并时应用 schema。
`acl-collection-status.json` 记录每个 XML 及其 volume 的处理结果和排除原因;`acl-last-run.json` 记录源 commit 与本批范围。`--collection P17` 可限定一个确实存在的 collection。已验证真实 ACL 2017 长文 195 条、短文 107 条,二者摘要完整;其他 track 按源实际缺失情况记录,不能凭样本宣称整个历史完成。
### ICLR 官方公开程序元数据
```sh
python3 -B -m paper_atlas collect iclr-program --cache /data/paper-index/raw/http-cache \
--out /data/paper-index/collected
# 入库后按官方主题检索;主题值以实际来源为准
python3 -B -m paper_atlas query --db /data/paper-index/papers.sqlite \
--venue ICLR --tag 'iclr_program:topic=Reinforcement Learning->Deep RL'
```
从 ICLR 官方年份导航登记届次,读取对应 Papers 页面明确引用的 orals-posters/abstracts JSON。核对声明数量、分页状态及唯一 event ID;只下载公开程序元数据,不访问用户资料、PDF、海报、幻灯片或视频。`--year` 可限制年份;清单中的已采集数与数据库已入库数分别统计。
官方程序中的 event 数不是论文数:同一篇文章可以有 poster 和 oral 两场展示。程序还会给负 sourceid 的 oral 展示生成占位 OpenReview 链接,这些链接不能作为真实论文标识。采集器核对原投稿编号、标题与作者后合并展示,优先使用正编号记录的真实论文链接;原展示编号和类型保留在源元数据中。
同一投稿编号的展示标题可能有小幅修订。只有负编号 oral 与正编号原稿对应、至少两位作者完全一致且标题相似度满足严格阈值时,才保留为同稿的标题变体;该规则仅在一届官方程序内部生效,不用于跨平台模糊合并。
只将 Conference 来源的论文视为 ICLR 主会录用,排除 Blog 和期刊转会展示,并在审计清单保存排除计数。2020/2021 旧数据允许依据官方主会 paper program 形成录用证据;无 forum ID 时保留 program 身份和匹配候选,不伪造标识。历史年度返回 404 会登记 unavailable,未来未发表届次登记 not_yet_published,其他访问或解析失败登记 blocked;这些状态都不能宣称该年已覆盖。
主题、关键词使用 `iclr_program:topic`、`iclr_program:keyword` 命名空间。通用导入器按源记录刷新标签证据,保留同一论文其他源记录及 `custom:*` 标签。每年的 JSONL、manifest、`iclr-program-year-status.json` 和 `iclr-program-last-run.json` 保存实际范围、独立展示数、主会展示数、论文数、缺失摘要与缺失 forum 数。录用程序不能提供拒稿全集,公开投稿及拒稿补充仍需独立来源。
### CVF / ECVA 视觉会议元数据
```sh
python3 -B -m paper_atlas collect cvf \
--cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
python3 -B -m paper_atlas collect ecva \
--cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
```
CVF 从官方根目录发现 CVPR、ICCV 主会与 Findings,分别保存 track;不把 workshop 或 WACV 算入这些会议。优先跟随页面明确提供的 All Papers 链接,避免只收录默认显示的第一天。ECVA 从官方总目录按论文 URL 的会议与年份分组,当前总目录列有 ECCV 2018、2020、2022、2024。不存在于来源的 `--venue`、`--year` 或二者组合会报错,不补造双年会议届次,也不能因此推断旧届次未举办。
只访问论文索引与摘要 HTML/PHP 页面。作者优先取 citation 元数据,旧页面尊重声明编码;无 citation 作者时取索引中的作者行,排除论文集出版信息。DOI 只取该论文条目所属的 DOI 链接或 citation 字段;冲突会失败,不从摘要引用中抽取当前论文 ID。不请求 PDF、补充材料、视频或代码。
每份清单记录索引入口数、论文数、缺失摘要与作者数,缺失内容保留为空并标记 partial。存在未被标题条目解析的论文链接时会失败,防止静默漏项。逐来源的 discovery、index-status、last-run 文件分别保存发现目录、逐届结果和实际请求范围。可重复使用 `--year` 或 `--venue` 限定重试;其他届次状态会保留。官方开放目录之外的早期届次、尚未刊出的新届次和独立出版方历史数据仍需后续补齐,采集器完成不代表全历史覆盖已完成。
+32
View File
@@ -0,0 +1,32 @@
# 开发说明
## 模块边界
| 目录 | 职责 |
| --- | --- |
| `paper_atlas/cli.py` | 注册命令并延迟加载对应模块,统一参数入口 |
| `paper_atlas/collectors/` | 各来源的发现、解析与清单输出,不直接写论文主库 |
| `paper_atlas/io/` | 公共 HTTP、原始快照、HTML 树与受限 Git 元数据读取 |
| `paper_atlas/ingestion/` | 核对清单、事务导入、维护锁和来源身份回填 |
| `paper_atlas/db/` | 论文与投稿身份解析、来源保留、合并审计和 schema |
| `paper_atlas/audits/` | 入库前来源对照及全部目标的覆盖报告 |
| `paper_atlas/querying.py` | 一致性只读快照上的查询 |
| `paper_atlas/resources/` | 随安装包提供的候选会议目录 |
采集器依赖公共 IO,导入器依赖存储层,查询和审计读取既有证据。不要让公共网络模块反向依赖某个采集器,也不要在 CLI 中嵌入来源解析逻辑。新增采集器需要注册命令、输出带来源/届次/track 的清单,并补充有意义的来源边界验证。
## 远程验证
所有测试、构建、打包及安装验证在确认的 `ces-control:host` 环境进行。本机只编辑源码和进行不触发构建的检查。
```sh
python3 -B -m unittest discover -s tests -t . -v
```
`.gitea/workflows/check.yml` 会运行回归测试,构建并安装 wheel,然后从源码目录之外检查 console 命令、模块入口、schema 和会议目录资源。测试中只使用独立临时库,不连接运行中的论文主库。
## 目录迁移
原根目录脚本已迁入对应模块,统一使用 `paper-atlas <command>` 或 `python -m paper_atlas <command>`。不再把脚本文件路径作为接口。SQL schema、来源 JSONL、manifest 及既有数据库结构未因这次封装改变。
部署使用固定 commit。仍在运行的旧批次继续使用已部署版本,待该批次完成后再切换后续任务;不要为了目录迁移重启已完成的全量导入。
+21
View File
@@ -0,0 +1,21 @@
# 运行维护与数据核验
代码以固定 commit 部署,已经启动的任务继续使用其原版本。目录重构不要求迁移数据库,也不更改已有 JSONL 和 manifest 格式。
## 长期维护与更新
1. 每次采集先核对来源、许可、可访问性、空闲空间和现有运行批次。保持低并发,遵守来源限流、重试与人机验证要求。
2. 下载先写 `.part`,确认完整 HTTP 响应和 ZIP 目录,再记录 SHA-256 与时间后切换为正式文件。正式导入会读至 EOF,从而验证成员 CRC。
3. 每次导入保留来源证据和批次,按会议/年份/track 报告覆盖状态。缺失摘要留空,不编造文本;缺失决定保留未知。
4. 核验行数、唯一标识、外键、全文查询和 SQLite 完整性后,再将批次视为交付。样本通过不能证明全量正确。
5. 数据库备份使用 SQLite backup API 或一致性快照,不能在写入过程中只复制 `.sqlite` 主文件而遗漏 WAL。按明确保留策略轮换备份,避免无上限积累。
6. 不为每次数据更新生成新 Docker 镜像;不常驻下载器;不自动创建定时任务。本项目的更新任务应按需或经用户授权调度。
7. 代码通过工作分支和 PR 交付;构建或 CI 只能使用用户确认的远程 runner,不在本机触发构建。
## 当前访问限制
2026-11-06 从目标服务器测试:Kaggle 元数据 GET 可下载;OpenReview 会议 group 接口可读,但 notes 接口返回 `ChallengeRequiredError`。这限制了直接补全;已使用来源可追溯的公开历史快照补充近期投稿与明确决定。仍不能用录用名单替代全部投稿,也不能声称覆盖了全部拒稿。
已核验的采集文件计数:ICLR 2020–2026 主会 15,534 条、COLM 2024–2026 共 1,573 条,摘要均已取得;候选范围内的 PMLR 文件为 31,428 条,已剔除两份重复出版元数据。ACL Anthology 已选文件为 63,297 条,其中 15,147 条源记录缺摘要。这些是采集文件的来源记录数,不能相加当作去重论文数,也不能代替主库实时统计;实际入库量使用 paper-atlas query --stats 查看。
代码在 `codex/metadata-index` 工作分支交付,[Draft PR #1](https://gitea.entroia.com/Zayden-Jung/paper-atlas/pulls/1) 尚未合并,等待全量就绪后用户手动合并。各提交分别由远程 CI 验证,结果以对应提交的 Gitea Actions 检查为准。全量导入、其他会议适配器、去重复核和覆盖率核验继续进行,应随实际结果更新此说明。
+45
View File
@@ -0,0 +1,45 @@
# 使用指南
先在已确认的远程环境安装项目,使用 `paper-atlas`。从源码运行时,将命令前缀替换为 `python3 -B -m paper_atlas`,其余参数相同。数据目录位于仓库外。
## 1. 下载固定快照
`paper-atlas download --help` 说明大小、校验和与续传参数。各来源的固定版本与具体命令见[数据源说明](data-sources.md)。下载成功只表示原始文件已核验。
## 2. 采集并核对清单
```sh
paper-atlas collect pmlr --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
paper-atlas collect cvf --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
paper-atlas collect ecva --cache /data/paper-index/raw/http-cache --out /data/paper-index/collected
paper-atlas catalogue dblp --help
paper-atlas audit iclr --help
```
采集输出 JSONL、manifest 和逐卷或逐年 catalogue。逐项检查 blocked、partial、缺失摘要及目标会议归属。DBLP 候选目录还需核验届次和 track,不能直接当作主会录用名单。
## 3. 串行导入
首次 arXiv 导入使用 `paper-atlas import arxiv --db ... --archive ...`。现有数据库合并已核验的会议文件使用:
```sh
paper-atlas maintain merge --db /data/paper-index/papers.sqlite \
--archive /data/paper-index/raw/arxiv-metadata.zip \
/data/paper-index/collected/selected-file.jsonl
```
上例的 selected-file 须换成实际通过核验的文件。协调器会维护本项目的 arXiv 断点、序列化写入并检查外键;已完成的 arXiv 批次不会重启。持久批次应作为一次性 systemd 任务运行,详见[运行维护](operations.md)。
## 4. 查询与分析前检查
```sh
paper-atlas query --db /data/paper-index/papers.sqlite --query '"language model"' --limit 20
paper-atlas query --db /data/paper-index/papers.sqlite --tag arxiv:category=cs.AI
paper-atlas query --db /data/paper-index/papers.sqlite --venue ICLR --year 2025 --status rejected
paper-atlas query --db /data/paper-index/papers.sqlite --paper 123
paper-atlas coverage --db /data/paper-index/papers.sqlite --out /data/paper-index/collected/coverage.json
```
`--status rejected` 筛选某次投稿的最新已知决定。它并不表示论文在所有会议均未录用;分析转投情况时查看该论文的完整投稿历史。年份表示会议届次,观察时间和决定日期分别保留。
`--stats` 中 papers 是当前规则下的论文实体数;source_records 是来源记录数;decisions 是决定观察数。一个投稿可能有多个来源和多条观察,不能相加当作论文数。
+3
View File
@@ -0,0 +1,3 @@
"""Paper Atlas research metadata toolkit."""
__version__ = '0.1.0'
+3
View File
@@ -0,0 +1,3 @@
from paper_atlas.cli import main
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
"""Audits."""
@@ -1,10 +1,11 @@
"""Show every target venue, including those with no imported records."""
import argparse
from paper_atlas.config import DEFAULT_REGISTRY
import json
from pathlib import Path
import sqlite3
from submission_evidence import timestamp
from paper_atlas.db.submissions import timestamp
def report(db,registry):
@@ -32,10 +33,14 @@ def report(db,registry):
finally:c.close()
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--db',required=True)
parser.add_argument('--registry',default=str(Path(__file__).with_name('venues.json')));parser.add_argument('--out')
args=parser.parse_args();result=report(args.db,args.registry);text=json.dumps(result,ensure_ascii=False,indent=2)
def main(argv=None, *, prog=None):
parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--db',required=True)
parser.add_argument('--registry',default=str(DEFAULT_REGISTRY));parser.add_argument('--out')
args=parser.parse_args(argv);result=report(args.db,args.registry);text=json.dumps(result,ensure_ascii=False,indent=2)
if args.out:
path=Path(args.out);stage=path.with_suffix('.part');stage.write_text(text+'\n');stage.replace(path)
else:print(text)
if __name__ == '__main__':
main()
@@ -5,8 +5,8 @@ import hashlib
import json
from pathlib import Path
from index_core import author_keys,normalized
from submission_evidence import identities_for_row,timestamp
from paper_atlas.db.store import author_keys,normalized
from paper_atlas.db.submissions import identities_for_row,timestamp
def load(path,source,year):
@@ -85,7 +85,11 @@ def audit(root,years,report_path):
return result
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--root',required=True);parser.add_argument('--report',required=True)
parser.add_argument('--year',type=int,action='append',required=True);args=parser.parse_args()
def main(argv=None, *, prog=None):
parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--root',required=True);parser.add_argument('--report',required=True)
parser.add_argument('--year',type=int,action='append',required=True);args=parser.parse_args(argv)
audit(args.root,args.year,args.report)
if __name__ == '__main__':
main()
+45
View File
@@ -0,0 +1,45 @@
"""One command tree; adapters are imported only when their command is selected."""
import argparse
from importlib import import_module
from paper_atlas import __version__
COMMANDS = {
('query',): ('paper_atlas.querying', {}, '查询论文、标签和投稿历史'),
('coverage',): ('paper_atlas.audits.coverage', {}, '查看全部候选会议的覆盖情况'),
('download',): ('paper_atlas.io.snapshots', {}, '下载并校验固定版本快照'),
('collect','pmlr'): ('paper_atlas.collectors.pmlr', {}, 'PMLR 官方元数据'),
('collect','acl'): ('paper_atlas.collectors.acl', {}, 'ACL Anthology XML'),
('collect','iclr-program'): ('paper_atlas.collectors.iclr', {}, 'ICLR 官方公开程序'),
('collect','iclr-snapshot'): ('paper_atlas.collectors.openreview', {}, 'ICLR 公开投稿与决定快照'),
('collect','peerread'): ('paper_atlas.collectors.peerread', {}, 'PeerRead 历史决定补充'),
('collect','colm'): ('paper_atlas.collectors.proceedings', {'source':'colm'}, 'COLM 官方论文集'),
('collect','neurips'): ('paper_atlas.collectors.proceedings', {'source':'neurips'}, 'NeurIPS 官方论文集'),
('collect','cvf'): ('paper_atlas.collectors.vision', {'source':'cvf'}, 'CVPR / ICCV 官方开放目录'),
('collect','ecva'): ('paper_atlas.collectors.vision', {'source':'ecva'}, 'ECCV 官方开放目录'),
('catalogue','dblp'): ('paper_atlas.collectors.dblp', {}, '盘点 DBLP 历史会议目录'),
('import','arxiv'): ('paper_atlas.ingestion.arxiv', {}, '导入 arXiv 元数据 ZIP'),
('import','records'): ('paper_atlas.ingestion.records', {}, '导入核验过的会议文件'),
('maintain','merge'): ('paper_atlas.ingestion.maintenance', {}, '协调单写入合并与恢复'),
('maintain','backfill'): ('paper_atlas.ingestion.backfill', {}, '回填既有投稿的来源身份'),
('audit','iclr'): ('paper_atlas.audits.iclr', {}, '核对 ICLR 快照与同届官方程序'),
}
def main(argv=None):
parser=argparse.ArgumentParser(prog='paper-atlas',description='Paper Atlas · 论文元数据与公开投稿历史')
parser.add_argument('--version',action='version',version='paper-atlas '+__version__)
commands=parser.add_subparsers(dest='command',required=True);groups={}
for route,(module,kwargs,help_text) in COMMANDS.items():
if len(route)==1:
leaf=commands.add_parser(route[0],help=help_text,add_help=False)
else:
if route[0] not in groups:
group=commands.add_parser(route[0],help={'collect':'采集来源元数据','catalogue':'盘点历史目录','import':'导入数据库','maintain':'维护数据库','audit':'核验数据'}[route[0]])
groups[route[0]]=group.add_subparsers(dest='operation',required=True)
leaf=groups[route[0]].add_parser(route[1],help=help_text,add_help=False)
leaf.set_defaults(handler=(route,module,kwargs))
args,forwarded=parser.parse_known_args(argv)
route,module,kwargs=args.handler
return import_module(module).main(forwarded,prog='paper-atlas '+' '.join(route),**kwargs)
+1
View File
@@ -0,0 +1 @@
"""Collectors."""
@@ -5,7 +5,7 @@ import re
import xml.etree.ElementTree as ET
from pathlib import Path
from metadata_checkout import checkout_metadata
from paper_atlas.io.git_metadata import checkout_metadata
VENUES={'acl':'ACL','emnlp':'EMNLP','naacl':'NAACL','eacl':'EACL','coling':'COLING','conll':'CoNLL'}
@@ -100,12 +100,12 @@ def collect_xml(path,out,commit):
return results
if __name__=='__main__':
parser=argparse.ArgumentParser()
def main(argv=None, *, prog=None):
parser=argparse.ArgumentParser(prog=prog)
parser.add_argument('--checkout',required=True)
parser.add_argument('--out',required=True)
parser.add_argument('--collection',action='append')
args=parser.parse_args()
args=parser.parse_args(argv)
commit=checkout_metadata('https://github.com/acl-org/acl-anthology.git',args.checkout,['/data/xml/'])
out=Path(args.out);out.mkdir(parents=True,exist_ok=True)
status_file=out/'acl-collection-status.json'
@@ -129,3 +129,7 @@ if __name__=='__main__':
part=status_file.with_suffix('.part');part.write_text(json.dumps(catalogue,indent=2));part.replace(status_file)
(out/'acl-last-run.json').write_text(json.dumps({'metadata_commit':commit,'collection_filter':args.collection,'blocked_collections':errors},indent=2))
if errors:raise SystemExit(1)
if __name__ == '__main__':
main()
@@ -11,8 +11,8 @@ from pathlib import Path
import sqlite3
from xml.parsers import expat
from download_snapshot import digest_file
from submission_evidence import timestamp
from paper_atlas.io.snapshots import digest_file
from paper_atlas.db.submissions import timestamp
XML_SHA='0376ad019b216553faff0a14e1757b2d4c7a3b84c1beacf0511ef83695211b18'
@@ -136,6 +136,10 @@ def catalogue(archive,dtd,out,expected_sha=XML_SHA,expected_dtd_md5=DTD_MD5):
if c is not None:c.close()
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--archive',required=True);parser.add_argument('--dtd',required=True);parser.add_argument('--out',required=True)
args=parser.parse_args();catalogue(args.archive,args.dtd,args.out)
def main(argv=None, *, prog=None):
parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--archive',required=True);parser.add_argument('--dtd',required=True);parser.add_argument('--out',required=True)
args=parser.parse_args(argv);catalogue(args.archive,args.dtd,args.out)
if __name__ == '__main__':
main()
@@ -9,9 +9,9 @@ import re
from urllib.error import HTTPError
from urllib.parse import parse_qs, urljoin, urlsplit
from collect_pmlr import fetch
from collect_proceedings import Document, clean
from index_core import author_keys, normalized
from paper_atlas.io.http import fetch
from paper_atlas.io.html import Document, clean
from paper_atlas.db.store import author_keys, normalized
ROOT = 'https://iclr.cc/'
SOURCE = 'iclr_program'
@@ -208,12 +208,12 @@ def collect_year(year, cache, out):
return manifest
def main():
parser = argparse.ArgumentParser()
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--cache', required=True)
parser.add_argument('--out', required=True)
parser.add_argument('--year', type=int, action='append')
args = parser.parse_args()
args = parser.parse_args(argv)
out = Path(args.out); out.mkdir(parents=True, exist_ok=True)
years = discover(fetch(ROOT, args.cache, 4*1024**2, delay=1).decode())
if args.year and set(args.year)-{year for year, _ in years}:
@@ -10,7 +10,7 @@ import sqlite3
import tarfile
import tempfile
from download_snapshot import digest_file
from paper_atlas.io.snapshots import digest_file
SOURCE='openreview_snapshot'
@@ -194,7 +194,11 @@ def collect(archive,out,years,expected_sha=SHA256):
return {year:catalogue[str(year)] for year in years}
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--archive',required=True);parser.add_argument('--out',required=True)
parser.add_argument('--year',type=int,action='append');args=parser.parse_args()
def main(argv=None, *, prog=None):
parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--archive',required=True);parser.add_argument('--out',required=True)
parser.add_argument('--year',type=int,action='append');args=parser.parse_args(argv)
collect(args.archive,args.out,args.year or sorted(EXPECTED))
if __name__ == '__main__':
main()
@@ -9,7 +9,7 @@ import urllib.request
from collections import defaultdict
from pathlib import Path
from collect_pmlr import fetch
from paper_atlas.io.http import fetch
def collect(cache,out):
@@ -63,9 +63,13 @@ def collect(cache,out):
print(json.dumps(manifest),flush=True)
if __name__=='__main__':
parser = argparse.ArgumentParser()
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--cache',required=True)
parser.add_argument('--out',required=True)
args = parser.parse_args()
args = parser.parse_args(argv)
collect(args.cache,args.out)
if __name__ == '__main__':
main()
@@ -1,17 +1,13 @@
"""Collect official PMLR metadata, never the PDFs linked by the metadata."""
import argparse
import hashlib
from http.client import IncompleteRead
from paper_atlas.config import DEFAULT_REGISTRY
import io
import json
import re
import os
import subprocess
import ssl
import tarfile
import time
import urllib.error
import urllib.request
from pathlib import Path
import yaml
@@ -40,45 +36,7 @@ def venue_key(shortname):
return re.sub(r'[\s_-]*(?:19|20)\d{2}$','',str(shortname).strip().upper())
def fetch(url,cache,limit=64*1024**2,delay=0.5):
cache = Path(cache)
cache.mkdir(parents=True,exist_ok=True)
path = cache/hashlib.sha256(url.encode()).hexdigest()
if path.exists() and time.time()-path.stat().st_mtime < 24*3600:
if path.stat().st_size>limit:raise RuntimeError('Metadata response exceeds byte limit')
return path.read_bytes()
for attempt in range(3):
try:
request = urllib.request.Request(url,headers={'User-Agent':'PaperAtlas/0.1 (public scholarly metadata research)'})
with urllib.request.urlopen(request,timeout=90) as response:
if response.status != 200:
raise RuntimeError('Expected complete HTTP 200 response')
expected=int(response.headers.get('Content-Length','0'))
if expected>limit:
raise RuntimeError('Metadata response exceeds byte limit')
data = response.read(limit+1)
if len(data)>limit:
raise RuntimeError('Metadata response exceeds byte limit')
if expected and len(data)!=expected:
raise IncompleteRead(data,max(0,expected-len(data)))
part = path.with_suffix('.part')
part.write_bytes(data)
part.replace(path)
path.with_suffix('.json').write_text(json.dumps({'url':url,'sha256':hashlib.sha256(data).hexdigest(),'bytes':len(data),'retrieved_at':time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())}))
time.sleep(delay)
return data
except urllib.error.HTTPError as exc:
if exc.code not in (429,500,502,503,504) or attempt==2:
exc.close()
raise
retry_after=exc.headers.get('Retry-After','1');exc.close()
try:retry_after=int(retry_after)
except ValueError:retry_after=5
time.sleep(min(30,max(2**attempt,retry_after)))
except (urllib.error.URLError,TimeoutError,ConnectionError,IncompleteRead,ssl.SSLError) as exc:
reason=getattr(exc,'reason',exc)
if isinstance(reason,ssl.SSLCertVerificationError) or attempt==2:raise
time.sleep(min(30,2**(attempt+1)))
from paper_atlas.io.http import fetch
def metadata_texts(volume,cache):
@@ -194,13 +152,13 @@ def collect_volume(volume,cache,out,allowed=None):
return result
def main():
parser = argparse.ArgumentParser()
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--cache',required=True)
parser.add_argument('--out',required=True)
parser.add_argument('--volume',type=int,action='append')
parser.add_argument('--registry',default=str(Path(__file__).with_name('venues.json')))
args = parser.parse_args()
parser.add_argument('--registry',default=str(DEFAULT_REGISTRY))
args = parser.parse_args(argv)
allowed = {venue_key(v['acronym']) for v in json.loads(Path(args.registry).read_text())['venues']}
volumes = args.volume
if not volumes:
@@ -4,61 +4,15 @@ import json
import re
import time
from collections import defaultdict
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urljoin,urlsplit
from collect_pmlr import fetch
from paper_atlas.io.http import fetch
ROOTS={'colm':'https://proceedings.colm.cc/','neurips':'https://proceedings.neurips.cc/'}
class Node:
def __init__(self,tag='',attrs=None):
self.tag=tag
self.attrs={key:value or '' for key,value in attrs or []}
self.children=[]
def text(self):
if self.tag in ('script','style'):
return ''
content=''.join(child.text() if isinstance(child,Node) else child for child in self.children)
return ' '+content+' ' if self.tag in ('p','div','section','li','br') else content
def walk(self):
yield self
for child in self.children:
if isinstance(child,Node):
yield from child.walk()
class Document(HTMLParser):
VOID={'area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr'}
def __init__(self,html):
super().__init__(convert_charrefs=True)
self.root=Node()
self.stack=[self.root]
self.feed(html)
def handle_starttag(self,tag,attrs):
node=Node(tag,attrs)
self.stack[-1].children.append(node)
if tag not in self.VOID:
self.stack.append(node)
def handle_endtag(self,tag):
for index in range(len(self.stack)-1,0,-1):
if self.stack[index].tag==tag:
del self.stack[index:]
break
def handle_data(self,data):
self.stack[-1].children.append(data)
def clean(text):
return ' '.join(text.split())
from paper_atlas.io.html import Document,Node,clean
def track_key(text):
@@ -188,13 +142,14 @@ def collect_index(source,index_url,year,cache,out):
return results
if __name__=='__main__':
parser=argparse.ArgumentParser()
parser.add_argument('--source',choices=ROOTS,required=True)
def main(argv=None, *, prog=None, source=None):
parser=argparse.ArgumentParser(prog=prog)
if source is None:parser.add_argument('--source',choices=ROOTS,required=True)
else:parser.set_defaults(source=source)
parser.add_argument('--cache',required=True)
parser.add_argument('--out',required=True)
parser.add_argument('--year',type=int,action='append')
args=parser.parse_args()
args=parser.parse_args(argv)
out=Path(args.out);out.mkdir(parents=True,exist_ok=True)
status_path=out/f'{args.source}-index-status.json'
catalogue=json.loads(status_path.read_text()) if status_path.exists() else {}
@@ -214,3 +169,7 @@ if __name__=='__main__':
part=status_path.with_suffix('.part');part.write_text(json.dumps(catalogue,indent=2));part.replace(status_path)
(out/f'{args.source}-last-run.json').write_text(json.dumps({'requested_indexes':requested,'year_filter':args.year},indent=2))
if any(catalogue[url]['status']=='blocked' for url in requested):raise SystemExit(1)
if __name__ == '__main__':
main()
@@ -7,8 +7,8 @@ import re
import time
from urllib.parse import parse_qs, unquote, urljoin, urlsplit
from collect_pmlr import fetch
from collect_proceedings import Document, Node, clean
from paper_atlas.io.http import fetch
from paper_atlas.io.html import Document, Node, clean
ROOTS={'cvf':'https://openaccess.thecvf.com/','ecva':'https://www.ecva.net/papers.php'}
HOSTS={'openaccess.thecvf.com','www.ecva.net','ecva.net'}
@@ -159,11 +159,13 @@ def collect_index(source,index,cache,out):
return manifest
def main():
parser=argparse.ArgumentParser();parser.add_argument('--source',choices=ROOTS,required=True)
def main(argv=None, *, prog=None, source=None):
parser=argparse.ArgumentParser(prog=prog)
if source is None:parser.add_argument('--source',choices=ROOTS,required=True)
else:parser.set_defaults(source=source)
parser.add_argument('--cache',required=True);parser.add_argument('--out',required=True)
parser.add_argument('--year',type=int,action='append');parser.add_argument('--venue',choices=['CVPR','ICCV','ECCV'],action='append')
args=parser.parse_args();out=Path(args.out);out.mkdir(parents=True,exist_ok=True)
args=parser.parse_args(argv);out=Path(args.out);out.mkdir(parents=True,exist_ok=True)
indexes=discover(args.source,args.cache)
if args.year and set(args.year)-{r['year'] for r in indexes}:parser.error('Requested year is absent from the official source')
if args.venue and set(args.venue)-{r['venue'] for r in indexes}:parser.error('Requested venue is absent from this source')
+4
View File
@@ -0,0 +1,4 @@
"""Read-only resources shipped with the installed package."""
from importlib.resources import files
DEFAULT_REGISTRY = files('paper_atlas.resources').joinpath('venues.json')
+1
View File
@@ -0,0 +1 @@
"""Db."""
+4 -3
View File
@@ -1,10 +1,11 @@
"""Shared identity resolution and provenance-preserving metadata storage."""
import json
from importlib.resources import files
import re
import sqlite3
import unicodedata
from pathlib import Path
from submission_evidence import attach_submission,migrate_sources,write_decision
from paper_atlas.db.submissions import attach_submission,migrate_sources,write_decision
def normalized(value):
@@ -29,7 +30,7 @@ def author_keys(authors):
class Store:
def __init__(self,path):
self.conn = sqlite3.connect(path,timeout=60)
self.conn.executescript(Path(__file__).with_name('schema.sql').read_text())
self.conn.executescript(files('paper_atlas.db').joinpath('schema.sql').read_text())
self.conn.execute('PRAGMA synchronous=NORMAL')
self.conn.execute('PRAGMA cache_size=-32768')
migrate_sources(self.conn)
@@ -146,7 +147,7 @@ class Store:
paper=c.execute('SELECT paper_id FROM source_records WHERE id=?',(record,)).fetchone()[0]
submission=attach_submission(self,edition,paper,record,external_id,identities or [])
if submitted_at:
from submission_evidence import timestamp
from paper_atlas.db.submissions import timestamp
c.execute('UPDATE submissions SET submitted_at=COALESCE(submitted_at,?) WHERE id=?',(timestamp(submitted_at),submission))
if status is not None:write_decision(self,submission,record,status,raw_value,url,observed_at,decision_date,source_decision_id)
return edition
+1
View File
@@ -0,0 +1 @@
"""Ingestion."""
@@ -7,7 +7,7 @@ import zipfile
from pathlib import Path
from index_core import Store
from paper_atlas.db.store import Store
def ingest(db, archive):
@@ -70,9 +70,13 @@ def ingest(db, archive):
conn.close()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--db', required=True)
parser.add_argument('--archive', required=True)
args = parser.parse_args()
args = parser.parse_args(argv)
ingest(args.db,args.archive)
if __name__ == '__main__':
main()
@@ -4,8 +4,8 @@ import hashlib
import json
from pathlib import Path
from index_core import Store
from submission_evidence import attach_submission,identities_for_row
from paper_atlas.db.store import Store
from paper_atlas.db.submissions import attach_submission,identities_for_row
def queue_files(path):
@@ -58,6 +58,10 @@ def backfill(db,files):
finally:c.close()
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--db',required=True);parser.add_argument('--queue',required=True)
args=parser.parse_args();backfill(args.db,queue_files(args.queue))
def main(argv=None, *, prog=None):
parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--db',required=True);parser.add_argument('--queue',required=True)
args=parser.parse_args(argv);backfill(args.db,queue_files(args.queue))
if __name__ == '__main__':
main()
@@ -9,11 +9,12 @@ import json
import fcntl
import sqlite3
import subprocess
import sys
from pathlib import Path
from import_records import ingest_file
from index_core import Store
from backfill_submission_evidence import backfill,queue_files
from paper_atlas.ingestion.records import ingest_file
from paper_atlas.db.store import Store
from paper_atlas.ingestion.backfill import backfill,queue_files
UNIT='paper-atlas-arxiv-import.service'
@@ -65,7 +66,7 @@ def merge_locked(db,archive,files,backfill_queue=None):
if property_value('LoadState')=='loaded':
subprocess.run(['systemctl','start',UNIT],check=True)
else:
subprocess.run(['systemd-run','--unit='+UNIT,'--property=WorkingDirectory='+str(Path(__file__).parent),'--property=CPUQuota=50%','--property=MemoryMax=512M','--property=Nice=15','--property=IOWeight=10','--property=StandardOutput=append:/data/paper-index/arxiv-import.log','--property=StandardError=append:/data/paper-index/arxiv-import.log','/usr/bin/env','PYTHONDONTWRITEBYTECODE=1','python3','-B',str(Path(__file__).with_name('import_arxiv.py')),'--db',db,'--archive',archive],check=True)
subprocess.run(['systemd-run','--unit='+UNIT,'--property=WorkingDirectory='+str(Path(__file__).resolve().parents[2]),'--property=CPUQuota=50%','--property=MemoryMax=512M','--property=Nice=15','--property=IOWeight=10','--property=StandardOutput=append:/data/paper-index/arxiv-import.log','--property=StandardError=append:/data/paper-index/arxiv-import.log','/usr/bin/env','PYTHONDONTWRITEBYTECODE=1',sys.executable,'-B','-m','paper_atlas','import','arxiv','--db',db,'--archive',archive],check=True)
if property_value('ActiveState')!='active':
raise RuntimeError('arXiv unit did not resume; inspect the logs')
print('ARXIV_RESUMED',flush=True)
@@ -78,14 +79,18 @@ def merge(db,archive,files,backfill_queue=None):
return merge_locked(db,archive,files,backfill_queue)
if __name__=='__main__':
parser=argparse.ArgumentParser()
def main(argv=None, *, prog=None):
parser=argparse.ArgumentParser(prog=prog)
parser.add_argument('--db',required=True)
parser.add_argument('--archive',required=True)
parser.add_argument('--schema-only',action='store_true',help='Apply idempotent schema/index updates at a saved checkpoint')
parser.add_argument('--backfill-queue',help='Audited existing-source queue for canonical submission evidence')
parser.add_argument('files',nargs='*')
args=parser.parse_args()
args=parser.parse_args(argv)
if not args.files and not args.schema_only and not args.backfill_queue:
parser.error('Provide collected files or --schema-only')
merge(args.db,args.archive,args.files,args.backfill_queue)
if __name__ == '__main__':
main()
@@ -4,8 +4,8 @@ import json
import time
from pathlib import Path
from index_core import Store
from submission_evidence import identities_for_row,timestamp
from paper_atlas.db.store import Store
from paper_atlas.db.submissions import identities_for_row,timestamp
def ingest_file(db,path):
@@ -84,10 +84,14 @@ def ingest_file(db,path):
c.close()
if __name__=='__main__':
parser = argparse.ArgumentParser()
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--db',required=True)
parser.add_argument('files',nargs='+')
args = parser.parse_args()
args = parser.parse_args(argv)
for filename in args.files:
ingest_file(args.db,filename)
if __name__ == '__main__':
main()
+1
View File
@@ -0,0 +1 @@
"""Io."""
+52
View File
@@ -0,0 +1,52 @@
"""Small HTML tree used by official metadata adapters."""
from html.parser import HTMLParser
class Node:
def __init__(self,tag='',attrs=None):
self.tag=tag
self.attrs={key:value or '' for key,value in attrs or []}
self.children=[]
def text(self):
if self.tag in ('script','style'):
return ''
content=''.join(child.text() if isinstance(child,Node) else child for child in self.children)
return ' '+content+' ' if self.tag in ('p','div','section','li','br') else content
def walk(self):
yield self
for child in self.children:
if isinstance(child,Node):
yield from child.walk()
class Document(HTMLParser):
VOID={'area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr'}
def __init__(self,html):
super().__init__(convert_charrefs=True)
self.root=Node()
self.stack=[self.root]
self.feed(html)
def handle_starttag(self,tag,attrs):
node=Node(tag,attrs)
self.stack[-1].children.append(node)
if tag not in self.VOID:
self.stack.append(node)
def handle_endtag(self,tag):
for index in range(len(self.stack)-1,0,-1):
if self.stack[index].tag==tag:
del self.stack[index:]
break
def handle_data(self,data):
self.stack[-1].children.append(data)
def clean(text):
return ' '.join(text.split())
+52
View File
@@ -0,0 +1,52 @@
"""Bounded public metadata requests and atomic response caching."""
import hashlib
from http.client import IncompleteRead
import json
from pathlib import Path
import ssl
import time
import urllib.error
import urllib.request
def fetch(url,cache,limit=64*1024**2,delay=0.5):
cache = Path(cache)
cache.mkdir(parents=True,exist_ok=True)
path = cache/hashlib.sha256(url.encode()).hexdigest()
if path.exists() and time.time()-path.stat().st_mtime < 24*3600:
if path.stat().st_size>limit:raise RuntimeError('Metadata response exceeds byte limit')
return path.read_bytes()
for attempt in range(3):
try:
request = urllib.request.Request(url,headers={'User-Agent':'PaperAtlas/0.1 (public scholarly metadata research)'})
with urllib.request.urlopen(request,timeout=90) as response:
if response.status != 200:
raise RuntimeError('Expected complete HTTP 200 response')
expected=int(response.headers.get('Content-Length','0'))
if expected>limit:
raise RuntimeError('Metadata response exceeds byte limit')
data = response.read(limit+1)
if len(data)>limit:
raise RuntimeError('Metadata response exceeds byte limit')
if expected and len(data)!=expected:
raise IncompleteRead(data,max(0,expected-len(data)))
part = path.with_suffix('.part')
part.write_bytes(data)
part.replace(path)
path.with_suffix('.json').write_text(json.dumps({'url':url,'sha256':hashlib.sha256(data).hexdigest(),'bytes':len(data),'retrieved_at':time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())}))
time.sleep(delay)
return data
except urllib.error.HTTPError as exc:
if exc.code not in (429,500,502,503,504) or attempt==2:
exc.close()
raise
retry_after=exc.headers.get('Retry-After','1');exc.close()
try:retry_after=int(retry_after)
except ValueError:retry_after=5
time.sleep(min(30,max(2**attempt,retry_after)))
except (urllib.error.URLError,TimeoutError,ConnectionError,IncompleteRead,ssl.SSLError) as exc:
reason=getattr(exc,'reason',exc)
if isinstance(reason,ssl.SSLCertVerificationError) or attempt==2:raise
time.sleep(min(30,2**(attempt+1)))
@@ -98,9 +98,13 @@ def download(url,output,sha256=None,md5=None,expected_bytes=None,resume=False,mi
return manifest
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--url',required=True);parser.add_argument('--output',required=True)
def main(argv=None, *, prog=None):
parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--url',required=True);parser.add_argument('--output',required=True)
parser.add_argument('--sha256');parser.add_argument('--md5');parser.add_argument('--expected-bytes',type=int)
parser.add_argument('--resume',action='store_true');parser.add_argument('--min-free-gib',type=float,default=8)
args=parser.parse_args()
args=parser.parse_args(argv)
print(json.dumps(download(args.url,args.output,args.sha256,args.md5,args.expected_bytes,args.resume,int(args.min_free_gib*1024**3))),flush=True)
if __name__ == '__main__':
main()
+3 -3
View File
@@ -5,8 +5,8 @@ import sqlite3
from pathlib import Path
def main():
parser = argparse.ArgumentParser()
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
parser.add_argument('--db', required=True)
parser.add_argument('--query', help='SQLite FTS5 query over title and abstract')
parser.add_argument('--paper',type=int,help='Paper ID; include sources, tags and submission history')
@@ -17,7 +17,7 @@ def main():
parser.add_argument('--status', choices=['accepted','rejected','desk_rejected','withdrawn','under_review','unknown'])
parser.add_argument('--limit', type=int, default=20)
parser.add_argument('--stats', action='store_true')
args = parser.parse_args()
args = parser.parse_args(argv)
conn = sqlite3.connect(Path(args.db).resolve().as_uri()+'?mode=ro', uri=True)
conn.row_factory = sqlite3.Row
conn.execute('BEGIN') # One consistent read snapshot while ingestion continues.
+1
View File
@@ -0,0 +1 @@
"""Resources."""
+22
View File
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "paper-atlas"
version = "0.1.0"
description = "Long-term, on-demand paper metadata and public submission history index"
readme = "README.md"
requires-python = ">=3.12"
dependencies = ["PyYAML==6.0.3"]
[project.scripts]
paper-atlas = "paper_atlas.cli:main"
[tool.setuptools.packages.find]
include = ["paper_atlas*"]
exclude = ["tests*"]
[tool.setuptools.package-data]
"paper_atlas.db" = ["schema.sql"]
"paper_atlas.resources" = ["venues.json"]
-1
View File
@@ -1 +0,0 @@
PyYAML==6.0.3
+1
View File
@@ -0,0 +1 @@
"""Tests."""
+1 -1
View File
@@ -4,7 +4,7 @@ import unittest
import xml.etree.ElementTree as ET
from pathlib import Path
from collect_acl import paper_id,volume_scope,collect_xml
from paper_atlas.collectors.acl import paper_id,volume_scope,collect_xml
class AnthologyTests(unittest.TestCase):
+32
View File
@@ -0,0 +1,32 @@
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from paper_atlas.cli import COMMANDS
class CliTests(unittest.TestCase):
def test_every_registered_command_has_help_without_starting_work(self):
for route in COMMANDS:
with self.subTest(route=route):
result=subprocess.run([sys.executable,'-B','-m','paper_atlas',*route,'--help'],capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
self.assertIn('paper-atlas '+' '.join(route),result.stdout)
def test_unified_import_and_query_use_shipped_schema_and_registry(self):
from paper_atlas.config import DEFAULT_REGISTRY
self.assertEqual(len(json.loads(DEFAULT_REGISTRY.read_text())['venues']),41)
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);db=root/'db.sqlite';source=root/'records.jsonl'
source.write_text(json.dumps({'source':'example','external_id':'one','url':'https://example.org','title':'CLI paper','abstract':'Abstract','authors':['Jane Doe'],'venue':'Example','year':2026,'track':'main','status':'accepted'})+'\n')
source.with_suffix('.manifest.json').write_text(json.dumps({'source':'example','list_url':'https://example.org','count':1}))
for args in [('import','records','--db',str(db),str(source)),('query','--db',str(db),'--stats')]:
result=subprocess.run([sys.executable,'-B','-m','paper_atlas',*args],capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
self.assertEqual(json.loads(result.stdout)['papers'],1)
if __name__=='__main__':unittest.main()
@@ -3,8 +3,8 @@ from pathlib import Path
import tempfile
import unittest
from coverage_report import report
from index_core import Store
from paper_atlas.audits.coverage import report
from paper_atlas.db.store import Store
class CoverageTests(unittest.TestCase):
@@ -6,7 +6,7 @@ import sqlite3
import tempfile
import unittest
from catalogue_dblp import catalogue,records
from paper_atlas.collectors.dblp import catalogue,records
class DblpCatalogueTests(unittest.TestCase):
@@ -6,7 +6,7 @@ import tempfile
import unittest
from unittest.mock import patch
from download_snapshot import download
from paper_atlas.io.snapshots import download
class Response(io.BytesIO):
@@ -19,16 +19,16 @@ class DownloadTests(unittest.TestCase):
body=b'public metadata';sha=hashlib.sha256(body).hexdigest()
with tempfile.TemporaryDirectory() as tmp:
path=Path(tmp)/'snapshot.gz'
with patch('download_snapshot.urllib.request.urlopen',return_value=Response(body)):
with patch('paper_atlas.io.snapshots.urllib.request.urlopen',return_value=Response(body)):
result=download('https://example.org/snapshot.gz',path,sha256=sha,expected_bytes=len(body),min_free=0)
self.assertEqual(result['status'],'verified')
with patch('download_snapshot.urllib.request.urlopen',side_effect=AssertionError('No network expected')):
with patch('paper_atlas.io.snapshots.urllib.request.urlopen',side_effect=AssertionError('No network expected')):
reused=download('https://example.org/snapshot.gz',path,sha256=sha,min_free=0)
self.assertEqual(reused['status'],'already_verified')
self.assertEqual(json.loads(Path(str(path)+'.manifest.json').read_text())['sha256'],sha)
def test_bad_checksum_never_publishes_a_snapshot(self):
with tempfile.TemporaryDirectory() as tmp,patch('download_snapshot.urllib.request.urlopen',return_value=Response(b'wrong bytes')):
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.snapshots.urllib.request.urlopen',return_value=Response(b'wrong bytes')):
path=Path(tmp)/'snapshot.gz'
with self.assertRaisesRegex(ValueError,'SHA256 mismatch'):
download('https://example.org/snapshot.gz',path,sha256='0'*64,min_free=0)
@@ -41,16 +41,16 @@ class DownloadTests(unittest.TestCase):
Path(str(part)+'.json').write_text(json.dumps({'url':url,'expected_sha256':sha,'expected_md5':None,'expected_bytes':6}))
with self.assertRaisesRegex(ValueError,'same snapshot'):
download(url,path,sha256=sha,expected_bytes=6,min_free=0)
with patch('download_snapshot.urllib.request.urlopen',return_value=Response(body)):
with patch('paper_atlas.io.snapshots.urllib.request.urlopen',return_value=Response(body)):
with self.assertRaisesRegex(ValueError,'resume range'):
download(url,path,sha256=sha,expected_bytes=6,resume=True,min_free=0)
self.assertEqual(part.read_bytes(),b'abc')
with patch('download_snapshot.urllib.request.urlopen',return_value=Response(b'def',206,{'Content-Range':'bytes 3-5/6'})):
with patch('paper_atlas.io.snapshots.urllib.request.urlopen',return_value=Response(b'def',206,{'Content-Range':'bytes 3-5/6'})):
download(url,path,sha256=sha,expected_bytes=6,resume=True,min_free=0)
self.assertEqual(path.read_bytes(),body)
def test_wrong_http_length_is_rejected_before_writing(self):
with tempfile.TemporaryDirectory() as tmp,patch('download_snapshot.urllib.request.urlopen',return_value=Response(b'abc')):
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.snapshots.urllib.request.urlopen',return_value=Response(b'abc')):
path=Path(tmp)/'snapshot.gz'
with self.assertRaisesRegex(ValueError,'HTTP size'):
download('https://example.org/snapshot.gz',path,sha256='0'*64,expected_bytes=4,min_free=0)
@@ -61,7 +61,7 @@ class DownloadTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
path=Path(tmp)/'snapshot.gz';part=Path(str(path)+'.part');part.write_bytes(b'')
Path(str(part)+'.json').write_text(json.dumps({'url':url,'expected_sha256':sha,'expected_md5':None,'expected_bytes':3}))
with patch('download_snapshot.urllib.request.urlopen',return_value=Response(body)):
with patch('paper_atlas.io.snapshots.urllib.request.urlopen',return_value=Response(body)):
download(url,path,sha256=sha,expected_bytes=3,resume=True,min_free=0)
metadata=Path(str(path)+'.manifest.json');metadata.unlink()
download(url,path,sha256=sha,expected_bytes=3,min_free=0)
+6 -6
View File
@@ -6,7 +6,7 @@ import unittest
from unittest.mock import patch
from urllib.error import HTTPError,URLError
from collect_pmlr import fetch
from paper_atlas.io.http import fetch
class Response(io.BytesIO):
@@ -16,30 +16,30 @@ class Response(io.BytesIO):
class FetchTests(unittest.TestCase):
def test_transient_connection_failure_is_retried(self):
with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.time.sleep'),patch('collect_pmlr.urllib.request.urlopen',side_effect=[URLError(ConnectionResetError('reset')),Response(b'complete')]) as network:
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.http.time.sleep'),patch('paper_atlas.io.http.urllib.request.urlopen',side_effect=[URLError(ConnectionResetError('reset')),Response(b'complete')]) as network:
self.assertEqual(fetch('https://example.org/metadata',tmp),b'complete')
self.assertEqual(network.call_count,2)
def test_truncated_response_is_not_cached_as_complete(self):
with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.time.sleep'),patch('collect_pmlr.urllib.request.urlopen',side_effect=[Response(b'bad',expected=8),Response(b'complete')]):
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.http.time.sleep'),patch('paper_atlas.io.http.urllib.request.urlopen',side_effect=[Response(b'bad',expected=8),Response(b'complete')]):
self.assertEqual(fetch('https://example.org/metadata',tmp),b'complete')
path=Path(tmp)/hashlib.sha256(b'https://example.org/metadata').hexdigest()
self.assertEqual(path.read_bytes(),b'complete')
def test_forbidden_response_is_not_retried_or_bypassed(self):
body=io.BytesIO(b'Forbidden');error=HTTPError('https://example.org/metadata',403,'Forbidden',{},body)
with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.time.sleep'),patch('collect_pmlr.urllib.request.urlopen',side_effect=error) as network:
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.http.time.sleep'),patch('paper_atlas.io.http.urllib.request.urlopen',side_effect=error) as network:
with self.assertRaises(HTTPError):fetch('https://example.org/metadata',tmp)
self.assertEqual(network.call_count,1)
self.assertTrue(body.closed)
def test_transport_retries_are_bounded(self):
with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.time.sleep'),patch('collect_pmlr.urllib.request.urlopen',side_effect=ConnectionResetError('reset')) as network:
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.http.time.sleep'),patch('paper_atlas.io.http.urllib.request.urlopen',side_effect=ConnectionResetError('reset')) as network:
with self.assertRaises(ConnectionResetError):fetch('https://example.org/metadata',tmp)
self.assertEqual(network.call_count,3)
def test_cached_response_still_obeys_the_size_limit(self):
with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.urllib.request.urlopen') as network:
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.io.http.urllib.request.urlopen') as network:
path=Path(tmp)/hashlib.sha256(b'https://example.org/metadata').hexdigest();path.write_bytes(b'too large')
with self.assertRaisesRegex(RuntimeError,'exceeds byte limit'):
fetch('https://example.org/metadata',tmp,limit=2)
+4 -4
View File
@@ -5,9 +5,9 @@ import unittest
from unittest.mock import patch
from urllib.error import HTTPError
from collect_iclr import asset_urls, collect_year, discover, parse_program
from import_records import ingest_file
from index_core import Store
from paper_atlas.collectors.iclr import asset_urls, collect_year, discover, parse_program
from paper_atlas.ingestion.records import ingest_file
from paper_atlas.db.store import Store
class ICLRTests(unittest.TestCase):
@@ -75,7 +75,7 @@ class ICLRTests(unittest.TestCase):
html=b'start("/static/virtual/data/iclr-2021-orals-posters.json",true,"/static/virtual/data/iclr-2021-abstracts.json")'
event=self.event(sourceurl=None,decision='Poster',abstract='Embedded historical abstract')
responses=[html,json.dumps({'count':1,'results':[event]}).encode(),HTTPError('https://iclr.cc/abstracts',404,'Not Found',None,None)]
with tempfile.TemporaryDirectory() as tmp,patch('collect_iclr.fetch',side_effect=responses):
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.iclr.fetch',side_effect=responses):
manifest=collect_year(2021,tmp,tmp)
self.assertEqual(manifest['status'],'collected')
self.assertEqual(manifest['missing_abstract_count'],0)
@@ -3,7 +3,7 @@ from pathlib import Path
import tempfile
import unittest
from audit_iclr_snapshot import audit
from paper_atlas.audits.iclr import audit
class SnapshotAuditTests(unittest.TestCase):
@@ -6,7 +6,7 @@ import unittest
import zipfile
from pathlib import Path
from import_arxiv import ingest
from paper_atlas.ingestion.arxiv import ingest
class ImportTests(unittest.TestCase):
@@ -4,8 +4,8 @@ import tempfile
import unittest
from pathlib import Path
from index_core import Store
from import_records import ingest_file
from paper_atlas.db.store import Store
from paper_atlas.ingestion.records import ingest_file
class StoreTests(unittest.TestCase):
@@ -3,8 +3,8 @@ import tempfile
import unittest
from unittest.mock import patch
from index_core import Store
from merge_conferences import merge
from paper_atlas.db.store import Store
from paper_atlas.ingestion.maintenance import merge
class MaintenanceTests(unittest.TestCase):
@@ -17,14 +17,14 @@ class MaintenanceTests(unittest.TestCase):
def test_completed_archive_is_not_started_again_after_maintenance(self):
with tempfile.TemporaryDirectory() as tmp:
db,archive=self.fixture(tmp,'complete')
with patch('merge_conferences.property_value',return_value='inactive'),patch('merge_conferences.subprocess.run') as run:
with patch('paper_atlas.ingestion.maintenance.property_value',return_value='inactive'),patch('paper_atlas.ingestion.maintenance.subprocess.run') as run:
merge(db,archive,[])
run.assert_not_called()
def test_inactive_incomplete_arxiv_requires_investigation(self):
with tempfile.TemporaryDirectory() as tmp:
db,archive=self.fixture(tmp,'failed')
with patch('merge_conferences.property_value',return_value='inactive'),patch('merge_conferences.subprocess.run') as run:
with patch('paper_atlas.ingestion.maintenance.property_value',return_value='inactive'),patch('paper_atlas.ingestion.maintenance.subprocess.run') as run:
with self.assertRaisesRegex(RuntimeError,'neither active nor verified complete'):
merge(db,archive,[])
run.assert_not_called()
@@ -32,7 +32,7 @@ class MaintenanceTests(unittest.TestCase):
def test_arxiv_finishing_at_the_stop_boundary_is_not_restarted(self):
with tempfile.TemporaryDirectory() as tmp:
db,archive=self.fixture(tmp,'complete')
with patch('merge_conferences.property_value',return_value='active'),patch('merge_conferences.subprocess.run') as run:
with patch('paper_atlas.ingestion.maintenance.property_value',return_value='active'),patch('paper_atlas.ingestion.maintenance.subprocess.run') as run:
merge(db,archive,[])
self.assertEqual(run.call_args_list[0].args[0],['systemctl','stop','paper-atlas-arxiv-import.service'])
self.assertEqual(run.call_count,1)
@@ -7,8 +7,8 @@ import tarfile
import tempfile
import unittest
from collect_openreview_snapshot import collect,decision_record,paper_record
from import_records import ingest_file
from paper_atlas.collectors.openreview import collect,decision_record,paper_record
from paper_atlas.ingestion.records import ingest_file
class SnapshotTests(unittest.TestCase):
+4 -4
View File
@@ -5,9 +5,9 @@ import unittest
from pathlib import Path
from unittest.mock import patch
from collect_peerread import collect
from import_records import ingest_file
from index_core import Store
from paper_atlas.collectors.peerread import collect
from paper_atlas.ingestion.records import ingest_file
from paper_atlas.db.store import Store
class PeerReadTests(unittest.TestCase):
@@ -16,7 +16,7 @@ class PeerReadTests(unittest.TestCase):
paper={'conference':'ICLR 2017 conference submission','id':'123','title':'Public paper','abstract':'Abstract','authors':'Jane Doe, John Smith','accepted':False,'reviews':[{'text':'Do not persist this review body'}]}
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp)
with patch('collect_peerread.fetch',side_effect=[json.dumps({'sha':'a'*40}).encode(),json.dumps(tree).encode()]),patch('collect_peerread.urllib.request.urlopen',return_value=io.BytesIO(json.dumps(paper).encode())),patch('collect_peerread.time.sleep'):
with patch('paper_atlas.collectors.peerread.fetch',side_effect=[json.dumps({'sha':'a'*40}).encode(),json.dumps(tree).encode()]),patch('paper_atlas.collectors.peerread.urllib.request.urlopen',return_value=io.BytesIO(json.dumps(paper).encode())),patch('paper_atlas.collectors.peerread.time.sleep'):
collect(root/'cache',root/'out')
path=root/'out/peerread-iclr2017-main.jsonl'
record=json.loads(path.read_text())
+4 -4
View File
@@ -4,7 +4,7 @@ import json
from pathlib import Path
from unittest.mock import patch
from collect_pmlr import load_metadata,venue_key,collect_volume
from paper_atlas.collectors.pmlr import load_metadata,venue_key,collect_volume
class MetadataTests(unittest.TestCase):
@@ -27,7 +27,7 @@ class MetadataTests(unittest.TestCase):
def test_missing_abstract_is_reported_as_partial_collection(self):
config=b'shortname: ICML\nyear: 2025\nbooktitle: ICML\n'
paper='---\nid: paper1\nlayout: inproceedings\ntitle: Paper\nabstract:\n---\n'
with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.fetch',return_value=config),patch('collect_pmlr.metadata_texts',return_value=iter([('paper.md',paper)])):
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.pmlr.fetch',return_value=config),patch('paper_atlas.collectors.pmlr.metadata_texts',return_value=iter([('paper.md',paper)])):
result=collect_volume(999,tmp,tmp)
self.assertEqual(result['count'],1)
self.assertEqual(result['missing_abstract_count'],1)
@@ -37,7 +37,7 @@ class MetadataTests(unittest.TestCase):
config=b'shortname: CoRL\nyear: 2023\nbooktitle: CoRL\n'
paper='---\nid: paper1\nlayout: inproceedings\ntitle: Paper\nabstract: Real abstract\ndate: {date}\n---\n'
posts=[('2023-11-13-paper1.md',paper.format(date='2023-11-13')),('2023-12-02-paper1.md',paper.format(date='2023-12-02'))]
with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.fetch',return_value=config),patch('collect_pmlr.metadata_texts',return_value=iter(posts)):
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.pmlr.fetch',return_value=config),patch('paper_atlas.collectors.pmlr.metadata_texts',return_value=iter(posts)):
result=collect_volume(229,tmp,tmp)
rows=[json.loads(line) for line in Path(result['file']).read_text().splitlines()]
self.assertEqual(result['count'],1)
@@ -49,7 +49,7 @@ class MetadataTests(unittest.TestCase):
def test_conflicting_duplicate_posts_do_not_replace_previous_collection(self):
config=b'shortname: CoRL\nyear: 2023\nbooktitle: CoRL\n'
paper='---\nid: paper1\nlayout: inproceedings\ntitle: {title}\nabstract: Abstract\n---\n'
with tempfile.TemporaryDirectory() as tmp,patch('collect_pmlr.fetch',return_value=config),patch('collect_pmlr.metadata_texts',return_value=iter([('a.md',paper.format(title='First paper')),('b.md',paper.format(title='Other paper'))])):
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.pmlr.fetch',return_value=config),patch('paper_atlas.collectors.pmlr.metadata_texts',return_value=iter([('a.md',paper.format(title='First paper')),('b.md',paper.format(title='Other paper'))])):
previous=Path(tmp)/'pmlr-v229.jsonl';previous.write_text('previous valid collection')
with self.assertRaisesRegex(ValueError,'Conflicting metadata posts'):
collect_volume(229,tmp,tmp)
@@ -1,11 +1,10 @@
import unittest
import json
import runpy
import tempfile
from pathlib import Path
from unittest.mock import patch
from collect_proceedings import paper_links,parse_paper
from paper_atlas.collectors.proceedings import paper_links,parse_paper,main
class ProceedingsTests(unittest.TestCase):
@@ -50,9 +49,9 @@ class ProceedingsTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
status=Path(tmp)/'colm-index-status.json'
status.write_text(json.dumps({other:{'status':'collected','files':[]}}))
argv=['collect_proceedings.py','--source','colm','--cache',tmp,'--out',tmp,'--year','2024']
with patch('sys.argv',argv),patch('collect_pmlr.fetch',side_effect=lambda u,*a,**kw:documents[u].encode()):
runpy.run_path(str(Path(__file__).with_name('collect_proceedings.py')),run_name='__main__')
argv=['paper-atlas','--source','colm','--cache',tmp,'--out',tmp,'--year','2024']
with patch('sys.argv',argv),patch('paper_atlas.collectors.proceedings.fetch',side_effect=lambda u,*a,**kw:documents[u].encode()):
main()
result=json.loads(status.read_text())
self.assertEqual(result[other]['status'],'collected')
self.assertEqual(result[url]['files'][0]['count'],1)
+5 -5
View File
@@ -5,7 +5,7 @@ import tempfile
import unittest
from pathlib import Path
from index_core import Store
from paper_atlas.db.store import Store
class QueryTests(unittest.TestCase):
@@ -16,7 +16,7 @@ class QueryTests(unittest.TestCase):
store.decision(paper,record,'COLING+LREC',2024,'main','paper1','https://example.org/1','accepted','official XML',['COLING','LREC'])
store.conn.commit()
for venue in ('COLING','LREC'):
command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(db),'--venue',venue,'--track','main']
command=[sys.executable,'-B','-m','paper_atlas','query','--db',str(db),'--venue',venue,'--track','main']
rows=json.loads(subprocess.run(command,check=True,capture_output=True,text=True).stdout)
self.assertEqual([r['id'] for r in rows],[paper])
self.assertEqual(store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0],1)
@@ -30,15 +30,15 @@ class QueryTests(unittest.TestCase):
store.decision(paper,record,'ICLR',2017,'main','paper1','https://example.org/paper1','rejected','explicit rejection')
store.record('arxiv','2601.00001','https://example.org/other','Another paper','Abstract',['John Smith'])
store.conn.commit()
command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(db),'--venue','iclr','--year','2017','--status','rejected']
command=[sys.executable,'-B','-m','paper_atlas','query','--db',str(db),'--venue','iclr','--year','2017','--status','rejected']
result=json.loads(subprocess.run(command,check=True,capture_output=True,text=True).stdout)
self.assertEqual([r['id'] for r in result],[paper])
details_command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(db),'--paper',str(paper)]
details_command=[sys.executable,'-B','-m','paper_atlas','query','--db',str(db),'--paper',str(paper)]
details=json.loads(subprocess.run(details_command,check=True,capture_output=True,text=True).stdout)[0]
self.assertEqual(details['authors'],['Jane Doe'])
self.assertEqual(details['submissions'][0]['decisions'][0]['status'],'rejected')
self.assertEqual(details['sources'][0]['external_id'],'paper1')
stats_command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(db),'--stats']
stats_command=[sys.executable,'-B','-m','paper_atlas','query','--db',str(db),'--stats']
stats=json.loads(subprocess.run(stats_command,check=True,capture_output=True,text=True).stdout)
self.assertEqual(stats['papers'],2)
self.assertEqual({r['name']:r['imported_records'] for r in stats['records_by_source']},{'arxiv':1,'peerread':1})
@@ -1,3 +1,4 @@
from importlib.resources import files
import json
from pathlib import Path
import sqlite3
@@ -6,10 +7,10 @@ import sys
import tempfile
import unittest
from import_records import ingest_file
from index_core import Store
from submission_evidence import identities_for_row
from backfill_submission_evidence import backfill,queue_files
from paper_atlas.ingestion.records import ingest_file
from paper_atlas.db.store import Store
from paper_atlas.db.submissions import identities_for_row
from paper_atlas.ingestion.backfill import backfill,queue_files
class SubmissionTests(unittest.TestCase):
@@ -25,7 +26,7 @@ class SubmissionTests(unittest.TestCase):
def query(self,*args):
self.store.conn.commit()
command=[sys.executable,'-B',str(Path(__file__).with_name('query.py')),'--db',str(self.db),*args]
command=[sys.executable,'-B','-m','paper_atlas','query','--db',str(self.db),*args]
result=subprocess.run(command,capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
return json.loads(result.stdout)
@@ -125,7 +126,7 @@ class SubmissionTests(unittest.TestCase):
identities_for_row({'source':'snapshot','venue':'ICLR','submission_identifiers':[{'scheme':'openreview','value':'2026-Oral--42-placeholder'}]})
def test_legacy_database_migration_preserves_source_and_decision_links(self):
legacy=Path(self.tmp.name)/'legacy.sqlite';sql=Path(__file__).with_name('schema.sql').read_text()
legacy=Path(self.tmp.name)/'legacy.sqlite';sql=files('paper_atlas.db').joinpath('schema.sql').read_text()
sql=sql[:sql.index('CREATE TABLE IF NOT EXISTS submission_sources')]+sql[sql.index('CREATE TABLE IF NOT EXISTS tags'):]
c=sqlite3.connect(legacy);c.executescript(sql)
c.execute("INSERT INTO sources VALUES(1,'legacy','https://example.org')")
+3 -3
View File
@@ -2,7 +2,7 @@ import tempfile
import unittest
from unittest.mock import patch
from collect_vision import collect_index, decode_html, discover, index_entries, parse_paper
from paper_atlas.collectors.vision import collect_index, decode_html, discover, index_entries, parse_paper
class VisionTests(unittest.TestCase):
@@ -46,7 +46,7 @@ class VisionTests(unittest.TestCase):
second='https://openaccess.thecvf.com/content/CVPR2026/html/Second_CVPR_2026_paper.html'
def item(url):return f'<dt class="ptitle"><a href="{url}">Paper</a></dt><dd>Jane Doe, John Smith</dd><dd>[<a href="never-download.pdf">pdf</a>]</dd>'
pages={root:f'<a href="?day=all">All papers</a><dl>{item(first)}</dl>',all_url:f'<dl>{item(first)}{item(second)}</dl>',first:'<div id="papertitle">First paper</div><div id="abstract">First abstract</div>',second:'<div id="papertitle">Second paper</div><p>Footer without an abstract</p>'}
with tempfile.TemporaryDirectory() as tmp,patch('collect_vision.fetch',side_effect=lambda url,*a,**kw:pages[url].encode()) as fetch:
with tempfile.TemporaryDirectory() as tmp,patch('paper_atlas.collectors.vision.fetch',side_effect=lambda url,*a,**kw:pages[url].encode()) as fetch:
manifest=collect_index('cvf',{'venue':'CVPR','year':2026,'track':'main','index_url':root},tmp,tmp)
self.assertEqual(manifest['count'],2)
self.assertEqual(manifest['list_url'],all_url)
@@ -56,7 +56,7 @@ class VisionTests(unittest.TestCase):
def test_discovery_keeps_findings_separate_and_excludes_workshops(self):
html='<a href="/CVPR2026">Main</a><a href="/CVPR2026_findings">Findings</a><a href="/ICCV2025">Main</a><a href="/CVPR2013.py">Legacy main</a><a href="/CVPR2026_workshops/menu">Workshops</a><a href="/WACV2026">WACV</a>'
with patch('collect_vision.fetch',return_value=html.encode()):indexes=discover('cvf','unused')
with patch('paper_atlas.collectors.vision.fetch',return_value=html.encode()):indexes=discover('cvf','unused')
self.assertEqual({(x['venue'],x['year'],x['track']) for x in indexes},{('CVPR',2013,'main'),('CVPR',2026,'main'),('CVPR',2026,'findings'),('ICCV',2025,'main')})
def test_unparsed_publication_link_does_not_silently_reduce_coverage(self):