Compare commits

..

5 Commits

Author SHA1 Message Date
codex-bot e5381d0e70 Verify short historical years and preserve mixed publication tracks
Metadata integrity checks / metadata (push) Successful in 24s
2026-11-06 18:38:53 +08:00
codex-bot b644de428b Normalize verified DBLP conference editions and publication metadata
Metadata integrity checks / metadata (push) Successful in 32s
2026-11-06 18:34:13 +08:00
codex-bot 6ae46b6717 Normalize endings in extracted documentation and shared utilities
Metadata integrity checks / metadata (push) Successful in 1m26s
2026-11-06 17:33:42 +08:00
codex-bot 1c18cbe03a Package Paper Atlas with organized modules and a unified CLI
Metadata integrity checks / metadata (push) Successful in 1m12s
2026-11-06 17:32:24 +08:00
Zayden-Jung dc7be9d6a4 Merge pull request 'Add paper metadata indexing and public submission collectors' (#1) from codex/metadata-index into main
Metadata integrity checks / metadata (push) Successful in 21s
Reviewed-on: #1
2026-11-06 09:21:32 +00:00
65 changed files with 1249 additions and 521 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 交付,由用户手动合并。
+20
View File
@@ -0,0 +1,20 @@
# 数据模型与身份解析
| 表 | 用途 |
| --- | --- |
| `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:*` 命名空间。
+239
View File
@@ -0,0 +1,239 @@
# 数据范围与采集器
## 数据范围与来源
- 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,也不更改论文主库。
目录盘点完成后,可按已核对的书目名称整理指定会议的分届文件。例如先补齐尚无主库记录的会议:
```sh
paper-atlas collect dblp \
--catalogue /data/paper-index/collected/dblp/dblp-catalogue.sqlite \
--archive /data/paper-index/raw/dblp-2026-11-01.xml.gz \
--dtd /data/paper-index/raw/dblp-2023-06-28.dtd \
--venue AAAI --venue IJCAI --venue KDD \
--out /data/paper-index/collected/dblp-normalized
```
也可用 `--candidates` 指定已通过清单 SHA-256 核验的候选文件;完整 XML 模式则可包含初次候选族之外的实际目录,例如 CLeaR 的 `clear2`。规则按书目名、crossref 论文集和会议年核对:不把另一个 `CLEAR` 或 Adaptive Agents 会议误标为 CLeaR/AAMAS;联合会议保留名称别名,出版年与会议年分别保存。workshop 默认单独留待审核,可显式使用 `--include-workshops`;其他已识别的官方附属 track 单列。
输出按会议/年份/track 分文件,附 manifest、`dblp-normalization.json` 范围审查统计和带哈希的 `dblp-import-queue.json`。不能识别的书目、年份或归属会逐类登记,不默认算作主会。DBLP 不含摘要,因此输出摘要为空、清单为 partial;需要通过有证据的跨来源关联补齐论文实体摘要。书目发表证据与原始审稿决定分别保留,不依据书目缺席推断拒稿。已存在其他出版来源的届次,还须先回填其权威身份并核验镜像记录,不能直接累加投稿数。
部分早期 SIGIR、CIKM、IJCAI 出版卷使用两位年份键;只有论文集标题明确给出唯一完整年份且与短键相符时才采用,不推测世纪。WWW 特殊兴趣、Alternate track 单列;同卷混合 Applied Data Science、Demo、Industrial 且不能逐篇区分时使用 `proceedings_unspecified`,不把整卷归为某一个具体 track。
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()
+46
View File
@@ -0,0 +1,46 @@
"""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 官方开放目录'),
('collect','dblp'): ('paper_atlas.collectors.dblp_records', {}, '核对并整理 DBLP 历史发表记录'),
('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()
+172
View File
@@ -0,0 +1,172 @@
"""Normalize pinned DBLP publication metadata into audited edition files."""
import argparse
from collections import Counter,OrderedDict
from contextlib import closing
import fcntl
import hashlib
import json
from pathlib import Path
import re
import shutil
import sqlite3
import tempfile
from urllib.parse import parse_qs,unquote,urlsplit
from paper_atlas.config import DEFAULT_REGISTRY
from paper_atlas.collectors.dblp import XML_SHA,DTD_MD5,RELEASE,records
from paper_atlas.collectors.dblp_scope import scope
from paper_atlas.db.submissions import timestamp
from paper_atlas.io.snapshots import digest_file
AS_OF='2026-11-01T00:00:00.000Z'
FRONTMATTER=re.compile(r'(?:preface|foreword|editorial|welcome(?: message)?|organizing committee|program committee|title page|table of contents)[.!:]?',re.I)
def identifiers(fields):
dois=set();arxiv=set();forums=set()
for link in fields.get('ee',[]):
parsed=urlsplit(link);path=unquote(parsed.path)
doi=None
if parsed.netloc in ('doi.org','dx.doi.org','doi.ieeecomputersociety.org'):doi=path.lstrip('/')
elif parsed.netloc=='link.springer.com' and path.startswith('/chapter/'):doi=path.removeprefix('/chapter/')
elif parsed.netloc=='dl.acm.org':
match=re.match(r'/doi/(?:abs/|full/|pdf/)?(10\..+)',path)
if match:doi=match[1]
if doi and re.fullmatch(r'10\.\d{4,9}/\S+',doi):dois.add(doi.lower())
if parsed.netloc in ('arxiv.org','www.arxiv.org'):
match=re.fullmatch(r'/(?:abs|pdf)/((?:\d{4}\.\d{4,5}|[a-zA-Z.-]+/\d{7})(?:v\d+)?)(?:\.pdf)?',path)
if match:arxiv.add(re.sub(r'v\d+$','',match[1]))
if parsed.netloc=='openreview.net' and parsed.path in ('/forum','/pdf'):
values=parse_qs(parsed.query).get('id',[])
if len(values)==1 and re.fullmatch(r'[A-Za-z0-9_-]+',values[0]) and not re.match(r'20\d{2}-Oral-',values[0]):forums.add(values[0])
for doi in dois:
match=re.fullmatch(r'10\.48550/arxiv\.((?:\d{4}\.\d{4,5}|[a-z.-]+/\d{7})(?:v\d+)?)',doi)
if match:arxiv.add(re.sub(r'v\d+$','',match[1]))
publications={doi for doi in dois if not doi.startswith('10.48550/arxiv.')}
own={}
for scheme,values in (('doi',publications),('arxiv',arxiv),('openreview',forums)):
if len(values)==1:own[scheme]=next(iter(values))
ambiguous={scheme:sorted(values) for scheme,values in (('doi',publications),('arxiv',arxiv),('openreview',forums)) if len(values)>1}
return own,ambiguous
def normalize_row(row,volumes,targets,include_workshops=False,through_year=2026):
if row['record_type']!='inproceedings':return None,'not_a_paper'
fields=row['fields'];refs=fields.get('crossref',[])
if len(refs)!=1:return None,'ambiguous_or_missing_crossref'
edition,reason=scope(row,volumes.get(refs[0]),through_year)
if not edition:return None,reason
if not ({edition['venue']}|set(edition['venue_aliases']))&targets:return None,'outside_requested_venues'
if edition['track']=='workshop' and not include_workshops:return None,'separate_workshop'
titles=fields.get('title',[])
if len(titles)!=1 or not titles[0].strip():return None,'missing_or_multiple_titles'
title=' '.join(titles[0].split())
if FRONTMATTER.fullmatch(title):return None,'frontmatter'
own,ambiguous=identifiers(fields)
authors=[re.sub(r'\s+\d{4}$','',name).strip() for name in fields.get('author',[]) if name.strip()]
metadata={'snapshot_sha256':XML_SHA,'snapshot_release':RELEASE,'observation_date_precision':'day',
'proceedings_key':refs[0],'proceedings_title':edition['proceedings_title'],'bibliographic_year':edition['bibliographic_year'],
'edition_year_basis':edition['edition_year_basis'],
'scope_rule':edition['rule'],'author_names_raw':fields.get('author',[]),'publtype':row.get('publtype'),
'bibliography':{key:value for key,value in fields.items() if key in ('booktitle','year','ee','url','pages','volume','number','publisher','series','isbn')},
'ambiguous_identifiers':ambiguous,'publication_evidence':'inproceedings bibliography; not an original decision note'}
flagged=bool(re.search(r'\b(?:withdrawn|retracted)\b',row.get('publtype') or '',re.I))
result={'source':'dblp','external_id':row['key'],'url':'https://dblp.org/rec/'+row['key']+'.html',
'title':title,'abstract':None,'authors':authors,'identifiers':own,
**{key:edition[key] for key in ('venue','venue_aliases','year','track')},
'status':'unknown' if flagged else 'accepted','observed_at':AS_OF,
'raw_decision':'listed in pinned DBLP inproceedings bibliography','metadata':metadata,
'tags':[{'namespace':'dblp:booktitle','value':fields['booktitle'][0]}]}
if flagged:result['decision_events']=[]
return result,None
def collect(catalogue,out,venues=None,candidates=None,archive=None,dtd=None,include_workshops=False,through_year=2026):
if bool(candidates)==bool(archive):raise ValueError('Choose exactly one candidates file or original archive')
catalogue=Path(catalogue);manifest=json.loads(catalogue.with_name('dblp-ai-candidates.manifest.json').read_text())
if manifest['snapshot_sha256']!=XML_SHA:raise ValueError('Unsupported snapshot identity')
if candidates:
if digest_file(Path(candidates))['sha256']!=manifest['candidate_sha256']:raise ValueError('Candidate checksum mismatch')
else:
if not dtd or digest_file(Path(dtd))['md5']!=DTD_MD5:raise ValueError('DTD checksum mismatch')
if digest_file(Path(archive))['sha256']!=XML_SHA:raise ValueError('Archive checksum mismatch')
with closing(sqlite3.connect(catalogue.resolve().as_uri()+'?mode=ro',uri=True)) as c:
volumes={key:json.loads(payload) for key,payload in c.execute('SELECT key,metadata_json FROM proceedings')}
if len(volumes)!=manifest['record_types']['proceedings']:raise ValueError('Catalogue inventory mismatch')
allowed={row['acronym'].casefold():row['acronym'] for row in json.loads(DEFAULT_REGISTRY.read_text())['venues']}
if venues and set(name.casefold() for name in venues)-allowed.keys():raise ValueError('Unknown registry venue')
targets={allowed[name.casefold()] for name in venues} if venues else set(allowed.values())
out=Path(out);out.mkdir(parents=True,exist_ok=True)
with (out/'dblp.normalize.lock').open('a') as lock:
fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
return _collect_rows(volumes,out,targets,candidates,archive,dtd,include_workshops,through_year,manifest)
def _collect_rows(volumes,out,targets,candidates,archive,dtd,include_workshops,through_year,source_manifest):
counts=Counter();excluded=Counter();examples={};groups={};seen=set();handles=OrderedDict();scanned=0
def rows():
if candidates:
with Path(candidates).open() as stream:
for line in stream:yield json.loads(line)
else:yield from records(archive,Path(dtd).read_bytes())
if shutil.disk_usage(out).free<8*1024**3:raise RuntimeError('Less than 8 GiB free before normalization')
with tempfile.TemporaryDirectory(prefix='.dblp-normalize-',dir=out) as temporary:
stage=Path(temporary)
try:
for row in rows():
scanned+=1
record,reason=normalize_row(row,volumes,targets,include_workshops,through_year)
if record is None:
excluded[reason]+=1
label=reason+'|'+'/'.join(row.get('key','').split('/')[:2])+'|'+';'.join(row['fields'].get('booktitle',[]))
if label not in examples:examples[label]={'count':0,'example_key':row.get('key')}
examples[label]['count']+=1
else:
key=record['external_id']
if key in seen:raise ValueError('Repeated DBLP publication key')
seen.add(key);group=(record['venue'],record['year'],record['track'])
filename='dblp-'+re.sub(r'[^a-z0-9]+','-',group[0].casefold())+'-'+str(group[1])+'-'+group[2]+'.jsonl'
if group not in groups:groups[group]={'filename':filename,'count':0,'unknown':0,'ambiguous_identifiers':0}
info=groups[group];info['count']+=1;info['unknown']+=record['status']=='unknown';info['ambiguous_identifiers']+=bool(record['metadata']['ambiguous_identifiers'])
if group not in handles:
if len(handles)>=32:handles.popitem(last=False)[1].close()
handles[group]=(stage/filename).open('a')
handles.move_to_end(group);handles[group].write(json.dumps(record,ensure_ascii=False)+'\n');counts[record['venue']]+=1
if scanned%50000==0:
if shutil.disk_usage(out).free<8*1024**3:raise RuntimeError('Less than 8 GiB free during normalization')
print(json.dumps({'source':'dblp','scanned':scanned,'normalized':sum(counts.values())}),flush=True)
finally:
for handle in handles.values():handle.close()
expected=source_manifest['candidate_records'] if candidates else sum(source_manifest['record_types'].values())
if scanned!=expected:raise ValueError('Input count differs from verified catalogue')
if not seen:raise ValueError('No target publications were verified')
files=[]
for (venue,year,track),info in sorted(groups.items()):
path=out/info['filename'];checksum=digest_file(stage/info['filename'])['sha256']
item={'source':'dblp','parser_version':1,'venue':venue,'year':year,'track':track,'count':info['count'],
'missing_abstract_count':info['count'],'unknown_status_count':info['unknown'],'ambiguous_identifier_count':info['ambiguous_identifiers'],
'list_url':RELEASE,'status':'partial','file':str(path),'file_sha256':checksum,'snapshot_sha256':XML_SHA,
'abstract_note':'DBLP provides no abstract; canonical abstracts must come from independently linked sources.'}
(stage/info['filename']).replace(path)
meta=path.with_suffix('.manifest.json');part=meta.with_suffix('.part');part.write_text(json.dumps(item,indent=2));part.replace(meta);files.append(item)
result={'source':'dblp','finished_at':timestamp(),'snapshot_sha256':XML_SHA,'input_mode':'candidates' if candidates else 'original_archive',
'requested_venues':sorted(targets),'include_workshops':include_workshops,'through_year':through_year,'scanned_records':scanned,
'normalized_records':len(seen),'records_by_venue':dict(counts),'excluded_by_reason':dict(excluded),'scope_review':examples,'files':files,
'input_candidate_sha256':source_manifest['candidate_sha256'] if candidates else None,
'note':'Classification covers requested recognizable bibliography entries, not an independent proof of complete venue histories. Unknown scopes remain in scope_review.'}
target=out/'dblp-normalization.json';part=target.with_suffix('.part');part.write_text(json.dumps(result,ensure_ascii=False,indent=2));part.replace(target)
queue={'files':[item['file'] for item in files],'file_sha256':{item['file']:item['file_sha256'] for item in files}}
target=out/'dblp-import-queue.json';part=target.with_suffix('.part');part.write_text(json.dumps(queue,indent=2));part.replace(target)
print(json.dumps({key:result[key] for key in ('scanned_records','normalized_records','records_by_venue','excluded_by_reason')}),flush=True)
return result
def main(argv=None,*,prog=None):
parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--catalogue',required=True);parser.add_argument('--out',required=True)
inputs=parser.add_mutually_exclusive_group(required=True);inputs.add_argument('--candidates');inputs.add_argument('--archive')
parser.add_argument('--dtd');parser.add_argument('--venue',action='append');parser.add_argument('--include-workshops',action='store_true')
parser.add_argument('--through-year',type=int,default=2026);args=parser.parse_args(argv)
collect(args.catalogue,args.out,args.venue,args.candidates,args.archive,args.dtd,args.include_workshops,args.through_year)
if __name__=='__main__':main()
+96
View File
@@ -0,0 +1,96 @@
"""Explicit bibliographic aliases; a directory family alone is not venue evidence."""
import re
# Ordered long/joint names precede their shorter forms.
BOOKS = {
'aaai': [('AAAI/IAAI','AAAI+IAAI',['AAAI','IAAI']),('AAAI','AAAI',[])],
'ijcai': [('IJCAI/ECAI','ECAI+IJCAI',['ECAI','IJCAI']),('IJCAI','IJCAI',[])],
'ecai': [('ECAI','ECAI',[]),('AISB/GI (ECAI)','ECAI',[]),('AISB (ECAI)','ECAI',[])],
'nips': [('NeurIPS Datasets and Benchmarks','NeurIPS',[]),('NeurIPS','NeurIPS',[]),('NIPS','NeurIPS',[])],
'icml': [('ICML','ICML',[]),('ML','ICML',[])], 'iclr':[('ICLR','ICLR',[])],
'colm':[('COLM','COLM',[])], 'colt':[('COLT/EuroCOLT','COLT',[]),('COLT','COLT',[])],
'aistats':[('AISTATS','AISTATS',[])], 'uai':[('UAI','UAI',[])],
'clear2':[('CLeaR','CLeaR',[])],
'acl':[('COLING-ACL','ACL+COLING',['ACL','COLING']),('ACL/IJCNLP','ACL',[]),('ACL','ACL',[])],
'emnlp':[('EMNLP-CoNLL','CoNLL+EMNLP',['CoNLL','EMNLP']),('EMNLP/IJCNLP','EMNLP',[]),('EMNLP','EMNLP',[])],
'naacl':[('HLT/EMNLP','EMNLP',[]),('NAACL-HLT','NAACL',[]),('HLT-NAACL','NAACL',[]),('NAACL','NAACL',[]),('HLT','NAACL',[])],
'eacl':[('EACL','EACL',[])],
'coling':[('LREC/COLING','COLING+LREC',['COLING','LREC']),('COLING-ACL','ACL+COLING',['ACL','COLING']),('COLING','COLING',[])],
'lrec':[('LREC/COLING','COLING+LREC',['COLING','LREC'])],
'conll':[('CoNLL/LLL','CoNLL',[]),('CoNLL','CoNLL',[])],
'cvpr':[('CVPR','CVPR',[])], 'iccv':[('ICCVW','ICCV',[]),('ICCV','ICCV',[])],
'eccv':[('ECCV','ECCV',[])], 'kdd':[('KDD','KDD',[])], 'sigir':[('SIGIR','SIGIR',[])],
'www':[('WWW','WWW',[])], 'wsdm':[('WSDM','WSDM',[])], 'recsys':[('RecSys','RecSys',[])],
'cikm':[('CIKM','CIKM',[])], 'icdm':[('ICDM','ICDM',[])], 'sdm':[('SDM','SDM',[])],
'ecml':[('ECML','ECML',['ECML-PKDD']),('EWSL','EWSL',['ECML-PKDD'])],
'pkdd':[('ECML/PKDD','ECML-PKDD',[]),('ECML PKDD','ECML-PKDD',[]),('PKDD','PKDD',['ECML-PKDD'])],
'icra':[('ICRA','ICRA',[])], 'iros':[('IROS','IROS',[])],
'rss':[('Robotics: Science and Systems','RSS',[])], 'corl':[('CoRL','CoRL',[])],
'aips':[('ICAPS','ICAPS',[]),('AIPS','AIPS',['ICAPS'])], 'icaps':[('ICAPS','ICAPS',[])],
'kr':[('KR','KR',[])], 'atal':[('AAMAS','AAMAS',[])],
'icassp':[('ICASSP','ICASSP',[])],
'interspeech':[('INTERSPEECH','INTERSPEECH',[]),('EUROSPEECH','EUROSPEECH',['INTERSPEECH']),('ICSLP','ICSLP',['INTERSPEECH']),('ECST','ECST',['INTERSPEECH'])],
'ismir':[('ISMIR','ISMIR',[])], 'miccai':[('MICCAI','MICCAI',[])],
'fat':[('FAccT','FAccT',[]),('FAT*','FAccT',[]),('FAT','FAccT',[])],
}
def scope(row,volume,through_year=2026):
fields=row['fields'];books=fields.get('booktitle',[])
if len(books)!=1:return None,'ambiguous_booktitle'
book=books[0];key=row.get('key','');parts=key.split('/')
family=parts[1] if len(parts)>=3 and parts[0]=='conf' else ''
if family not in BOOKS:return None,'outside_target_family'
if not volume:return None,'missing_proceedings'
title=' '.join(volume['fields'].get('title',[]))
if family=='clear2' and 'causal learning and reasoning' not in title.casefold():return None,'ambiguous_venue_name'
matched=None
for base,venue,aliases in BOOKS[family]:
match=re.fullmatch(re.escape(base)+r'(?:\s+\((.*)\)|\s+(.*)|,\s*(Vol\.\s*\d+))?',book,re.I)
if match:
matched=(base,venue,aliases,' '.join(value for value in match.groups() if value));break
if not matched:return None,'unclassified_booktitle'
base,venue,aliases,suffix=matched
volume_family=volume['key'].split('/')[1] if volume['key'].startswith('conf/') else ''
if volume_family!=family and not any(item[1]==venue for item in BOOKS.get(volume_family,[])):
return None,'crossref_venue_conflict'
year_match=re.match(r'conf/[^/]+/((?:19|20)\d{2})',volume['key'])
year_basis='proceedings key, checked against bibliographic year or explicit event year in proceedings title'
if year_match:year=int(year_match[1])
else:
# Old SIGIR/CIKM/IJCAI keys use two digits. Never guess their century.
short=re.fullmatch(r'conf/[^/]+/(\d{2})',volume['key'])
years=set(re.findall(r'\b(?:19|20)\d{2}\b',title))
if not short or len(years)!=1:return None,'unverified_edition_year'
year=int(next(iter(years)))
if year%100!=int(short[1]):return None,'unverified_edition_year'
year_basis='unique explicit event year in proceedings title, corroborated by two-digit proceedings key'
if year>through_year:return None,'future_edition'
bibliographic=fields.get('year',[])
if len(bibliographic)!=1 or not re.fullmatch(r'\d{4}',bibliographic[0]):return None,'ambiguous_bibliographic_year'
if int(bibliographic[0])!=year and not re.search(r'\b'+str(year)+r'\b',title):return None,'unverified_year_difference'
text=(suffix+' '+title).casefold();track='main'
if 'findings' in text:track='findings'
elif 'shared task' in text:track='shared_task'
elif 'student research' in text or 'workshop' in text or 'wokshop' in text or base in ('ICCVW','EWSL'):track='workshop'
elif ('demo track' in text and ('industrial track' in text or 'applied data science track' in text)):track='proceedings_unspecified'
elif 'demonstration' in text or re.search(r'\bdemos?\b',text):track='demo'
elif 'tutorial' in text:track='tutorial'
elif 'industry' in text or 'industrial track' in text or 'applied data science' in text:track='industry'
elif 'position paper' in text:track='position'
elif 'companion' in text:track='companion'
elif 'late-breaking' in text or 'late breaking' in text:track='late_breaking'
elif 'doctoral' in text:track='doctoral'
elif 'challenge' in text or 'competition' in text:track='competition'
elif 'bridge program' in text:track='bridge'
elif 'special interest tracks' in text:track='special_interest'
elif 'alternate track' in text:track='alternate'
elif base=='NeurIPS Datasets and Benchmarks':track='datasets_benchmarks'
elif suffix and not (re.fullmatch(r'(?:\d+|Vol\.\s*\d+|Volume \d+: (?:Long|Short) Papers|Long Papers|Short Papers|Selected Papers|Fall)',suffix,re.I)
or suffix.casefold() in ('poster','posters','oral','spotlight')):return None,'unclassified_track'
elif suffix.casefold() in ('poster','posters') and venue not in ('ICLR','ACL','EMNLP','NAACL','EACL','COLING','CoNLL'):track='poster'
if venue=='AAAI+IAAI':track='proceedings_unspecified'
if venue=='AAMAS' and year<2002:return None,'predecessor_not_aamas'
return {'venue':venue,'venue_aliases':aliases,'year':year,'track':track,'bibliographic_year':int(bibliographic[0]),
'rule':family+':'+base,'proceedings_title':title,'edition_year_basis':year_basis},None
@@ -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."""
+7 -5
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)
@@ -136,8 +137,9 @@ class Store:
def decision(self,paper,record,venue,year,track,external_id,url,status,raw_value,aliases=None,identities=None,observed_at=None,decision_date=None,source_decision_id='',submitted_at=None):
c = self.conn
c.execute("INSERT OR IGNORE INTO venues(acronym,name,field,venue_type) VALUES(?,?,?,'conference')",(venue,venue,'unclassified'))
venue_id = c.execute('SELECT id FROM venues WHERE acronym=?',(venue,)).fetchone()[0]
existing=c.execute('SELECT id FROM venues WHERE acronym=? COLLATE NOCASE',(venue,)).fetchone()
if existing:venue_id=existing[0]
else:venue_id=c.execute("INSERT INTO venues(acronym,name,field,venue_type) VALUES(?,?,?,'conference')",(venue,venue,'unclassified')).lastrowid
for alias in aliases or []:
c.execute('INSERT OR IGNORE INTO venue_aliases VALUES(?,?)',(venue_id,alias))
c.execute('INSERT OR IGNORE INTO editions(venue_id,year,track) VALUES(?,?,?)',(venue_id,int(year),track))
@@ -146,7 +148,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):
@@ -36,7 +36,7 @@ def backfill(db,files):
if any(row.get(key)!=manifest.get(key) for key in ('source','venue','year','track')):
raise ValueError('Evidence row differs from edition manifest')
record=c.execute('SELECT r.id,r.paper_id,r.metadata_json FROM source_records r JOIN sources s ON s.id=r.source_id WHERE s.name=? AND r.external_id=?',(row['source'],row['external_id'])).fetchone()
edition=c.execute('SELECT e.id FROM editions e JOIN venues v ON v.id=e.venue_id WHERE v.acronym=? AND e.year=? AND e.track=?',(row['venue'],row['year'],row['track'])).fetchone()
edition=c.execute('SELECT e.id FROM editions e JOIN venues v ON v.id=e.venue_id WHERE v.acronym=? COLLATE NOCASE AND e.year=? AND e.track=?',(row['venue'],row['year'],row['track'])).fetchone()
if not record or not edition:raise ValueError('Evidence backfill requires an already imported record and edition')
if not c.execute('SELECT 1 FROM submission_sources ss JOIN submissions s ON s.id=ss.submission_id WHERE ss.source_record_id=? AND ss.source_submission_id=? AND s.edition_id=?',(record[0],row['external_id'],edition[0])).fetchone():
raise ValueError('No existing submission to backfill')
@@ -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."""
+50
View File
@@ -0,0 +1,50 @@
"""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())
+50
View File
@@ -0,0 +1,50 @@
"""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):
+130
View File
@@ -0,0 +1,130 @@
import hashlib
import json
from contextlib import closing
from pathlib import Path
import sqlite3
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from paper_atlas.collectors.dblp import XML_SHA
from paper_atlas.collectors.dblp_records import collect,identifiers,normalize_row
from paper_atlas.collectors.dblp_scope import scope
from paper_atlas.db.store import Store
from paper_atlas.ingestion.records import ingest_file
class DblpRecordTests(unittest.TestCase):
def setUp(self):
# Tiny fixtures may live on a small tmpfs; production reserve stays 8 GiB.
budget=patch('paper_atlas.collectors.dblp_records.shutil.disk_usage',return_value=SimpleNamespace(free=16*1024**3))
budget.start();self.addCleanup(budget.stop)
def row(self,family='aaai',book='AAAI',year=2024,key='one'):
return {'record_type':'inproceedings','key':f'conf/{family}/{key}','publtype':None,
'fields':{'title':['A research publication'],'author':['Jane Doe 0001','John Smith'],
'booktitle':[book],'year':[str(year)],'crossref':[f'conf/{family}/{year}'],'ee':['https://doi.org/10.1234/'+key]}}
def volume(self,family,year,title=None):
return {'record_type':'proceedings','key':f'conf/{family}/{year}','fields':{'title':[title or f'{family.upper()} {year}'],'year':[str(year)]}}
def test_real_directory_aliases_do_not_confuse_other_conferences(self):
row=self.row('clear2','CLeaR');volume=self.volume('clear2',2024,'Causal Learning and Reasoning, 2024')
self.assertEqual(scope(row,volume)[0]['venue'],'CLeaR')
self.assertIsNone(scope(self.row('clear','CLEAR'),self.volume('clear',2024))[0])
self.assertIsNone(scope(self.row('aamas','Adaptive Agents and Multi-Agent Systems'),self.volume('aamas',2024))[0])
self.assertEqual(scope(self.row('atal','AAMAS'),self.volume('atal',2024))[0]['venue'],'AAMAS')
self.assertEqual(scope(self.row('aips','ICAPS'),self.volume('aips',2024))[0]['venue'],'ICAPS')
def test_conference_year_is_distinct_from_publication_year(self):
row=self.row('nips','NIPS',1988);row['fields']['crossref']=['conf/nips/1987']
volume=self.volume('nips',1987,'NIPS Conference, Denver, 1987; published 1988')
result,_=scope(row,volume);self.assertEqual(result['year'],1987);self.assertEqual(result['bibliographic_year'],1988)
volume['fields']['title']=['NIPS collected papers']
self.assertEqual(scope(row,volume)[1],'unverified_year_difference')
def test_joint_events_and_tracks_are_explicit(self):
result,_=scope(self.row('ijcai','IJCAI/ECAI',2022),self.volume('ijcai',2022))
self.assertEqual(result['venue'],'ECAI+IJCAI');self.assertEqual(result['venue_aliases'],['ECAI','IJCAI'])
result,_=scope(self.row('coling','LREC/COLING'),self.volume('coling',2024))
self.assertEqual(result['venue'],'COLING+LREC')
for book,track in [('ACL (Findings)','findings'),('ACL (Student Research Workshop)','workshop'),('ACL (System Demonstrations)','demo')]:
self.assertEqual(scope(self.row('acl',book),self.volume('acl',2024))[0]['track'],track)
for suffix,track in [('Special interest tracks and posters','special_interest'),('Alternate Track Papers & Posters','alternate')]:
self.assertEqual(scope(self.row('www','WWW ('+suffix+')'),self.volume('www',2024))[0]['track'],track)
volume=self.volume('pkdd',2026,'ECML PKDD 2026, Applied Data Science Track, Demo Track and Industrial Track')
self.assertEqual(scope(self.row('pkdd','ECML PKDD (10)',2026),volume)[0]['track'],'proceedings_unspecified')
def test_short_year_keys_require_a_unique_explicit_year_not_a_guessed_century(self):
row=self.row('sigir','SIGIR',1980);volume=self.volume('sigir',1980,'Information Retrieval Research, Cambridge, June 1980')
volume['key']='conf/sigir/80';volume['fields']['year']=['1981'];row['fields']['crossref']=[volume['key']]
edition,_=scope(row,volume);self.assertEqual(edition['year'],1980)
self.assertIn('two-digit',edition['edition_year_basis'])
volume['fields']['title']=['SIGIR 1980, published 1981']
self.assertEqual(scope(row,volume)[1],'unverified_edition_year')
volume['fields']['title']=['SIGIR 1989']
self.assertEqual(scope(row,volume)[1],'unverified_edition_year')
def test_crossref_mismatch_and_future_editions_are_held_for_review(self):
self.assertEqual(scope(self.row(),self.volume('kdd',2024))[1],'crossref_venue_conflict')
self.assertEqual(scope(self.row(year=2027),self.volume('aaai',2027))[1],'future_edition')
def test_only_own_identifier_links_are_used_and_ambiguity_is_preserved(self):
own,ambiguous=identifiers({'ee':['https://doi.org/10.1234/published','https://doi.org/10.48550/arXiv.2401.00001','https://openreview.net/forum?id=realForum'],
'cite':['https://doi.org/10.9999/reference']})
self.assertEqual(own,{'doi':'10.1234/published','arxiv':'2401.00001','openreview':'realForum'});self.assertFalse(ambiguous)
own,ambiguous=identifiers({'ee':['https://doi.org/10.1234/a','https://doi.org/10.1234/b','https://openreview.net/forum?id=2026-Oral--99-placeholder']})
self.assertEqual(own,{});self.assertEqual(len(ambiguous['doi']),2)
def fixture(self,root,rows):
catalogue=root/'dblp-catalogue.sqlite';volume=self.volume('aaai',2024)
with closing(sqlite3.connect(catalogue)) as c, c:
c.execute('CREATE TABLE proceedings(key TEXT PRIMARY KEY,metadata_json TEXT)')
c.execute('INSERT INTO proceedings VALUES(?,?)',(volume['key'],json.dumps(volume)))
candidates=root/'candidates.jsonl';candidates.write_text(''.join(json.dumps(row)+'\n' for row in rows))
manifest={'snapshot_sha256':XML_SHA,'candidate_sha256':hashlib.sha256(candidates.read_bytes()).hexdigest(),
'candidate_records':len(rows),'record_types':{'proceedings':1,'inproceedings':len(rows)}}
catalogue.with_name('dblp-ai-candidates.manifest.json').write_text(json.dumps(manifest))
return catalogue,candidates
def test_normalized_files_import_with_unknown_abstracts_and_no_invented_retracted_acceptance(self):
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);withdrawn=self.row(key='two');withdrawn['publtype']='withdrawn'
catalogue,candidates=self.fixture(root,[self.row(),withdrawn,self.row(book='AAAI Workshops',key='workshop')])
result=collect(catalogue,root/'out',['AAAI'],candidates=candidates)
self.assertEqual(result['normalized_records'],2);self.assertEqual(result['excluded_by_reason']['separate_workshop'],1)
manifest=result['files'][0];self.assertEqual(manifest['missing_abstract_count'],2)
rows=[json.loads(line) for line in Path(manifest['file']).read_text().splitlines()]
self.assertEqual(rows[0]['authors'],['Jane Doe','John Smith']);self.assertIsNone(rows[0]['abstract'])
self.assertEqual(rows[1]['status'],'unknown');self.assertEqual(rows[1]['decision_events'],[])
db=root/'papers.sqlite';ingest_file(db,manifest['file'])
with closing(sqlite3.connect(db)) as c:self.assertEqual(c.execute('SELECT count(*) FROM decisions').fetchone()[0],1)
def test_checksum_failure_and_duplicate_keys_do_not_publish_files(self):
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);catalogue,candidates=self.fixture(root,[self.row(),self.row()])
with self.assertRaisesRegex(ValueError,'Repeated DBLP'):collect(catalogue,root/'out',['AAAI'],candidates=candidates)
self.assertFalse(list((root/'out').glob('*.jsonl')))
candidates.write_text('tampered')
with self.assertRaisesRegex(ValueError,'checksum mismatch'):collect(catalogue,root/'out',['AAAI'],candidates=candidates)
def test_case_variants_use_one_conference_edition_and_submission(self):
with tempfile.TemporaryDirectory() as tmp:
store=Store(Path(tmp)/'papers.sqlite');self.addCleanup(store.conn.close)
paper,one=store.record('pmlr','one','https://example.org/one','A paper','Abstract',[],{'doi':'10.1234/one'})
store.decision(paper,one,'CORL',2024,'main','one','https://example.org/one','accepted','Accept',identities=[('publication_doi','10.1234/one')])
same,two=store.record('dblp','two','https://example.org/two','A paper',None,[],{'doi':'10.1234/one'})
store.decision(same,two,'CoRL',2024,'main','two','https://example.org/two','accepted','Published',identities=[('publication_doi','10.1234/one')])
self.assertEqual(store.conn.execute('SELECT count(*) FROM venues').fetchone()[0],1)
self.assertEqual(store.conn.execute('SELECT count(*) FROM submissions').fetchone()[0],1)
def test_production_disk_reserve_stops_before_publishing(self):
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);catalogue,candidates=self.fixture(root,[self.row()])
with patch('paper_atlas.collectors.dblp_records.shutil.disk_usage',return_value=SimpleNamespace(free=1024)):
with self.assertRaisesRegex(RuntimeError,'8 GiB'):collect(catalogue,root/'out',['AAAI'],candidates=candidates)
self.assertFalse(list((root/'out').glob('*.jsonl')))
if __name__=='__main__':unittest.main()
@@ -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):