textfold 0.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- textfold/__init__.py +2 -0
- textfold/__main__.py +134 -0
- textfold/agents.py +344 -0
- textfold/biorxiv.py +147 -0
- textfold/bridges.py +115 -0
- textfold/build.py +123 -0
- textfold/engine.py +288 -0
- textfold/epmc.py +88 -0
- textfold/fetch.py +65 -0
- textfold/fold.py +135 -0
- textfold/github.py +91 -0
- textfold/jats.py +395 -0
- textfold/kinds.py +49 -0
- textfold/ladder.py +296 -0
- textfold/langs.py +52 -0
- textfold/links.py +68 -0
- textfold/llm.py +204 -0
- textfold/llm_openai.py +101 -0
- textfold/local.py +143 -0
- textfold/markdown.py +115 -0
- textfold/models.py +144 -0
- textfold/notes.py +298 -0
- textfold/office.py +879 -0
- textfold/omml.py +204 -0
- textfold/parse.py +381 -0
- textfold/pdf.py +85 -0
- textfold/pdfdoc.py +517 -0
- textfold/pipeline.py +70 -0
- textfold/providers.py +205 -0
- textfold/semantic.py +567 -0
- textfold/server.py +682 -0
- textfold/store.py +231 -0
- textfold/tokens.py +130 -0
- textfold/translate.py +228 -0
- textfold/web/app.css +259 -0
- textfold/web/app.html +46 -0
- textfold/web/app.js +805 -0
- textfold/web/i18n.js +330 -0
- textfold/web/reader.css +768 -0
- textfold/web/reader.js +3344 -0
- textfold/web/template.html +28 -0
- textfold/web/tutorial.js +501 -0
- textfold/web/vendor/fonts/OFL-Inter.txt +93 -0
- textfold/web/vendor/fonts/OFL-SourceSerif4.txt +93 -0
- textfold/web/vendor/fonts/fonts.css +172 -0
- textfold/web/vendor/fonts/inter-normal-cyrillic-ext.woff2 +0 -0
- textfold/web/vendor/fonts/inter-normal-cyrillic.woff2 +0 -0
- textfold/web/vendor/fonts/inter-normal-greek-ext.woff2 +0 -0
- textfold/web/vendor/fonts/inter-normal-greek.woff2 +0 -0
- textfold/web/vendor/fonts/inter-normal-latin-ext.woff2 +0 -0
- textfold/web/vendor/fonts/inter-normal-latin.woff2 +0 -0
- textfold/web/vendor/fonts/inter-normal-vietnamese.woff2 +0 -0
- textfold/web/vendor/fonts/sourceserif4-italic-cyrillic-ext.woff2 +0 -0
- textfold/web/vendor/fonts/sourceserif4-italic-cyrillic.woff2 +0 -0
- textfold/web/vendor/fonts/sourceserif4-italic-greek.woff2 +0 -0
- textfold/web/vendor/fonts/sourceserif4-italic-latin-ext.woff2 +0 -0
- textfold/web/vendor/fonts/sourceserif4-italic-latin.woff2 +0 -0
- textfold/web/vendor/fonts/sourceserif4-italic-vietnamese.woff2 +0 -0
- textfold/web/vendor/fonts/sourceserif4-normal-cyrillic-ext.woff2 +0 -0
- textfold/web/vendor/fonts/sourceserif4-normal-cyrillic.woff2 +0 -0
- textfold/web/vendor/fonts/sourceserif4-normal-greek.woff2 +0 -0
- textfold/web/vendor/fonts/sourceserif4-normal-latin-ext.woff2 +0 -0
- textfold/web/vendor/fonts/sourceserif4-normal-latin.woff2 +0 -0
- textfold/web/vendor/fonts/sourceserif4-normal-vietnamese.woff2 +0 -0
- textfold/web/vendor/katex/LICENSE +21 -0
- textfold/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- textfold/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- textfold/web/vendor/katex/katex.min.css +1 -0
- textfold/web/vendor/katex/katex.min.js +1 -0
- textfold/web/vendor/pdfjs/LICENSE.txt +177 -0
- textfold/web/vendor/pdfjs/pdf.min.mjs +28 -0
- textfold/web/vendor/pdfjs/pdf.worker.min.mjs +28 -0
- textfold/webpage.py +119 -0
- textfold/wiki.py +199 -0
- textfold-0.10.0.dist-info/METADATA +262 -0
- textfold-0.10.0.dist-info/RECORD +99 -0
- textfold-0.10.0.dist-info/WHEEL +5 -0
- textfold-0.10.0.dist-info/entry_points.txt +2 -0
- textfold-0.10.0.dist-info/licenses/LICENSE +202 -0
- textfold-0.10.0.dist-info/licenses/NOTICE +17 -0
- textfold-0.10.0.dist-info/top_level.txt +1 -0
textfold/__init__.py
ADDED
textfold/__main__.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""TextFold.
|
|
2
|
+
|
|
3
|
+
python -m textfold serve [--port 3017] the local app (landing, sidebar, reader)
|
|
4
|
+
python -m textfold <arxiv id or url> [--lang zh] ... generate from the command line, then export
|
|
5
|
+
python -m textfold notes.md [--lang zh] ... the same for a Markdown file
|
|
6
|
+
python -m textfold export <arxiv id> one self-contained HTML file in out/<id>/
|
|
7
|
+
python3 -m textfold get <link or file> [--level topic] [--section 3] [--json] a document at one level (for a person or an agent)
|
|
8
|
+
python3 -m textfold engine status | install | remove the PDF engine (MinerU, about 3.4 GB)
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import build, local, pipeline
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _say(*a):
|
|
20
|
+
print(*a, file=sys.stderr, flush=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get(a):
|
|
24
|
+
"""A document at one level, on stdout: written first when it has no levels yet (a model is called), unless
|
|
25
|
+
--no-generate. A PDF (or a Word, PowerPoint, Excel, EPUB or HTML file, a picture) goes through the PDF engine; a
|
|
26
|
+
Markdown or text file is read as it is."""
|
|
27
|
+
from . import engine, fold, models, office, pdfdoc, store
|
|
28
|
+
f = Path(a.ref).expanduser()
|
|
29
|
+
ext = engine.kind_of(f.name) if f.is_file() else None
|
|
30
|
+
if ext:
|
|
31
|
+
data = f.read_bytes()
|
|
32
|
+
pid = pdfdoc.pid_of(data, ext)
|
|
33
|
+
if not (store.pdir(pid) / "meta.json").exists() and not engine.where() and ext != "pdf" and office.reads(ext):
|
|
34
|
+
office.save(pid, f, f.name) # (the engine reads it more completely, when it is installed)
|
|
35
|
+
elif not (store.pdir(pid) / "meta.json").exists():
|
|
36
|
+
if not engine.where():
|
|
37
|
+
what = "PDF" if ext == "pdf" else "picture" if ext in engine.PICTURES else f"{engine.FILES[ext]} file"
|
|
38
|
+
_say(f"This {what} needs the PDF engine: python3 -m textfold engine install")
|
|
39
|
+
return 2
|
|
40
|
+
tier = engine.tier_for(f"x.{ext}", models.load_settings().get("pdf_tier") or "fast")
|
|
41
|
+
out = store.DATA / "uploads" / f"{pid}-{tier}"
|
|
42
|
+
src = f
|
|
43
|
+
if f.suffix.lower() != f".{ext}": # (an extension the engine knows by another: .tif)
|
|
44
|
+
src = store.DATA / "uploads" / f"{pid}.{ext}"
|
|
45
|
+
src.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
src.write_bytes(data)
|
|
47
|
+
engine.convert(src, out, tier, progress=lambda s, d, t: _say(f" converting {d}/{t}") if t else None, log=lambda line: None)
|
|
48
|
+
pdfdoc.store_pdf(pid, out, pdfdoc.read_output(out), f.name, tier)
|
|
49
|
+
(store.pdir(pid) / pdfdoc.kept_name(pid, src)).write_bytes(data)
|
|
50
|
+
shutil.rmtree(out, ignore_errors=True) # the engine's output (and its copy of the file), stored
|
|
51
|
+
if src != f:
|
|
52
|
+
src.unlink(missing_ok=True)
|
|
53
|
+
elif f.is_file():
|
|
54
|
+
pid = local.import_file(f)
|
|
55
|
+
else:
|
|
56
|
+
pid = store.resolve(a.ref)
|
|
57
|
+
try:
|
|
58
|
+
spec = None if a.no_generate else models.resolve(None)
|
|
59
|
+
except RuntimeError:
|
|
60
|
+
spec = None
|
|
61
|
+
try:
|
|
62
|
+
pipeline.generate(pid, a.lang, spec, progress=lambda s, d, t: _say(f" {s} {d}/{t}") if t and s != "done" else None, log=lambda line: None)
|
|
63
|
+
except RuntimeError as e: # fetched and parsed; its levels need a model: the local ones, said so
|
|
64
|
+
if "No model" not in str(e):
|
|
65
|
+
raise
|
|
66
|
+
_say("No model: the written levels are the author's first sentences (connect one with python3 -m textfold serve, Settings).")
|
|
67
|
+
d = fold.document(pid, a.lang)
|
|
68
|
+
if a.json:
|
|
69
|
+
import json
|
|
70
|
+
print(json.dumps(d, ensure_ascii=False, indent=1))
|
|
71
|
+
else:
|
|
72
|
+
print(fold.markdown(d, a.level, a.section), end="")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def engine_cmd(args):
|
|
77
|
+
from . import engine
|
|
78
|
+
what = args[0] if args else "status"
|
|
79
|
+
if what == "install":
|
|
80
|
+
engine.install(progress=lambda s, d, t: _say(f" {s} ({d}/{t})"), log=_say)
|
|
81
|
+
elif what == "remove":
|
|
82
|
+
engine.uninstall()
|
|
83
|
+
elif what != "status":
|
|
84
|
+
_say("python3 -m textfold engine status | install | remove")
|
|
85
|
+
return 2
|
|
86
|
+
st = engine.status()
|
|
87
|
+
print(f"PDF engine: {'installed' if st['installed'] else 'not installed'} (MinerU {st['version']})"
|
|
88
|
+
+ (f", {st['bytes'] / 1e9:.1f} GB in {st['path']}" if st["installed"] else ""))
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main():
|
|
93
|
+
if sys.argv[1:2] == ["--version"]:
|
|
94
|
+
from . import __version__
|
|
95
|
+
return print(f"TextFold {__version__}")
|
|
96
|
+
if len(sys.argv) > 1 and sys.argv[1] == "serve":
|
|
97
|
+
ap = argparse.ArgumentParser(prog="python3 -m textfold serve")
|
|
98
|
+
ap.add_argument("--port", type=int, default=3017)
|
|
99
|
+
a = ap.parse_args(sys.argv[2:])
|
|
100
|
+
from .server import serve
|
|
101
|
+
return serve(a.port)
|
|
102
|
+
if len(sys.argv) > 1 and sys.argv[1] == "get":
|
|
103
|
+
from .fold import LEVELS
|
|
104
|
+
ap = argparse.ArgumentParser(prog="python3 -m textfold get", description="A document at one level: the map first, then the parts that matter, deeper.")
|
|
105
|
+
ap.add_argument("ref", help="a link (any web page, arXiv, PubMed Central, bioRxiv, Wikipedia, GitHub), an arXiv id, a DOI, or a file (.pdf, .docx, .pptx, .xlsx, .epub, .html, a picture, .md, .txt)")
|
|
106
|
+
ap.add_argument("--level", default="topic", choices=LEVELS, help="topic (a map of the sections) ... full (every word); default topic")
|
|
107
|
+
ap.add_argument("--section", help="one section: its number (3.2), its id (S3), or its place in the list (1 is the first)")
|
|
108
|
+
ap.add_argument("--lang", default="en", help="the language to read it in (en, zh, de, ...); default en")
|
|
109
|
+
ap.add_argument("--json", action="store_true", help="the whole document in the TextFold format (docs/format.md)")
|
|
110
|
+
ap.add_argument("--no-generate", action="store_true", help="call no model: the levels already written, or the local ones")
|
|
111
|
+
return sys.exit(get(ap.parse_args(sys.argv[2:])))
|
|
112
|
+
if len(sys.argv) > 1 and sys.argv[1] == "engine":
|
|
113
|
+
return sys.exit(engine_cmd(sys.argv[2:]))
|
|
114
|
+
if len(sys.argv) > 1 and sys.argv[1] == "export":
|
|
115
|
+
print(build.export(sys.argv[2]))
|
|
116
|
+
return
|
|
117
|
+
ap = argparse.ArgumentParser(prog="textfold", description="Generate a zoomable reader for an arXiv paper or a Markdown file.")
|
|
118
|
+
ap.add_argument("paper", help="arXiv id or URL, e.g. 2512.23916, or a Markdown file")
|
|
119
|
+
ap.add_argument("--lang", action="append", default=[], help="also generate this language (zh, ja, es, ...); repeatable")
|
|
120
|
+
ap.add_argument("--model", default="sonnet", help="model alias for `claude -p` (default: sonnet)")
|
|
121
|
+
ap.add_argument("--jobs", type=int, default=4, help="parallel model calls")
|
|
122
|
+
ap.add_argument("--force", action="store_true", help="regenerate (ignore what is stored and cached)")
|
|
123
|
+
a = ap.parse_args()
|
|
124
|
+
t0 = time.time()
|
|
125
|
+
ref = local.import_file(a.paper) if Path(a.paper).suffix.lower() in local.KINDS and Path(a.paper).is_file() else a.paper
|
|
126
|
+
pid = None
|
|
127
|
+
for lang in ["en"] + a.lang:
|
|
128
|
+
pid = pipeline.generate(ref, lang, a.model, force=a.force, jobs=a.jobs, log=print,
|
|
129
|
+
progress=lambda s, d, t: print(f" {s} {d}/{t}", end="\r"))
|
|
130
|
+
print(f"\n → {build.export(pid)} ({time.time() - t0:.0f}s)")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
main()
|
textfold/agents.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Coding agents already installed on this computer, ported from ThoughtDAG (runtime/agents/*.cjs).
|
|
2
|
+
|
|
3
|
+
The server scans for them at startup (and on demand): every mainstream agent CLI is looked for on PATH,
|
|
4
|
+
on the login shell's PATH, in the usual install directories and under the node version managers, as
|
|
5
|
+
ThoughtDAG's where.cjs does. Three can run a generation, the three ThoughtDAG drives:
|
|
6
|
+
|
|
7
|
+
claude-code claude -p, structured output by --json-schema models: fable, opus, sonnet, haiku
|
|
8
|
+
codex codex exec, structured output by --output-schema models: `codex app-server` model/list
|
|
9
|
+
pi pi -p --mode json, JSON asked for in the prompt models: `pi --mode rpc` get_available_models
|
|
10
|
+
|
|
11
|
+
The others are reported when found, but not driven yet. A model id is ThoughtDAG's:
|
|
12
|
+
`claude/claude-code/opus`, `codex/codex/<model>`, `pi/<provider>/<model>`.
|
|
13
|
+
Agents run here only as one-shot text generators: no tools, no session, a scratch working directory.
|
|
14
|
+
"""
|
|
15
|
+
import glob
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import select
|
|
20
|
+
import subprocess
|
|
21
|
+
import tempfile
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
HOME = Path.home()
|
|
27
|
+
# runtime, label, binary, extra directories, can it run a generation here
|
|
28
|
+
REGISTRY = [
|
|
29
|
+
("claude-code", "Claude Code", "claude", ["~/.claude/local"], True),
|
|
30
|
+
("codex", "Codex", "codex", ["~/.codex/bin"], True),
|
|
31
|
+
("pi", "Pi", "pi", ["~/.pi/bin"], True),
|
|
32
|
+
("gemini", "Gemini CLI", "gemini", [], False),
|
|
33
|
+
("qwen", "Qwen Code", "qwen", [], False),
|
|
34
|
+
("opencode", "opencode", "opencode", ["~/.opencode/bin"], False),
|
|
35
|
+
("cursor-agent", "Cursor Agent", "cursor-agent", [], False),
|
|
36
|
+
("kimi", "Kimi CLI", "kimi", [], False),
|
|
37
|
+
("amp", "Amp", "amp", [], False),
|
|
38
|
+
("goose", "Goose", "goose", [], False),
|
|
39
|
+
("crush", "Crush", "crush", [], False),
|
|
40
|
+
("droid", "Factory Droid", "droid", ["~/.factory/bin"], False),
|
|
41
|
+
("copilot", "GitHub Copilot CLI", "copilot", [], False),
|
|
42
|
+
("iflow", "iFlow CLI", "iflow", [], False),
|
|
43
|
+
("aider", "Aider", "aider", [], False),
|
|
44
|
+
]
|
|
45
|
+
PREFIX = {"claude-code": "claude/", "codex": "codex/", "pi": "pi/"}
|
|
46
|
+
COMMON = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "~/.bun/bin", "~/.npm-global/bin", "~/.local/bin"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _version_key(p):
|
|
50
|
+
return [int(x) if x.isdigit() else 0 for x in re.findall(r"\d+", p)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_login_path = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def search_dirs():
|
|
57
|
+
"""PATH, the login shell's PATH, common install dirs, version-manager dirs (ThoughtDAG where.cjs)."""
|
|
58
|
+
global _login_path
|
|
59
|
+
if _login_path is None:
|
|
60
|
+
shell = os.environ.get("SHELL", "/bin/zsh")
|
|
61
|
+
try:
|
|
62
|
+
cmd = [shell, "-l", "-c", "string join : $PATH"] if shell.endswith("fish") else [shell, "-ilc", 'printf "%s" "$PATH"']
|
|
63
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=4, stdin=subprocess.DEVNULL)
|
|
64
|
+
_login_path = (r.stdout.strip().splitlines() or [""])[-1]
|
|
65
|
+
except Exception:
|
|
66
|
+
_login_path = ""
|
|
67
|
+
dirs = os.environ.get("PATH", "").split(os.pathsep) + _login_path.split(":") + COMMON
|
|
68
|
+
managers = sorted(glob.glob(str(HOME / ".nvm/versions/node/*/bin")), key=_version_key, reverse=True)
|
|
69
|
+
for pattern in ["~/.fnm/node-versions/*/installation/bin", "~/Library/Application Support/fnm/node-versions/*/installation/bin",
|
|
70
|
+
"~/.volta/bin", "~/n/bin", "~/.nodenv/shims", "~/.asdf/shims", "~/.local/share/mise/shims", "~/.proto/bin",
|
|
71
|
+
"~/Library/pnpm", "~/.local/share/pnpm", "~/.yarn/bin", "~/.config/yarn/global/node_modules/.bin",
|
|
72
|
+
"~/.deno/bin", "~/.cargo/bin", "~/bin"]:
|
|
73
|
+
managers += sorted(glob.glob(os.path.expanduser(pattern)), key=_version_key, reverse=True)
|
|
74
|
+
out, seen = [], set()
|
|
75
|
+
for d in dirs + managers:
|
|
76
|
+
d = os.path.expanduser(d)
|
|
77
|
+
if d and d not in seen and os.path.isdir(d):
|
|
78
|
+
seen.add(d)
|
|
79
|
+
out.append(d)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def child_env():
|
|
84
|
+
"""Children get the widened PATH, so a node-based CLI finds node, and USER and LOGNAME even where the server was
|
|
85
|
+
started without them (launchd, cron, env -i): Claude Code finds its sign-in in the keychain under USER, and
|
|
86
|
+
without it says it is not logged in."""
|
|
87
|
+
env = {**os.environ, "PATH": os.pathsep.join(search_dirs())}
|
|
88
|
+
if not (env.get("USER") and env.get("LOGNAME")):
|
|
89
|
+
try:
|
|
90
|
+
import pwd # not on Windows, where neither is needed
|
|
91
|
+
name = pwd.getpwuid(os.getuid()).pw_name
|
|
92
|
+
except (ImportError, KeyError):
|
|
93
|
+
name = None
|
|
94
|
+
for k in ("USER", "LOGNAME"):
|
|
95
|
+
if name and not env.get(k):
|
|
96
|
+
env[k] = name
|
|
97
|
+
return env
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def locate(binary, extra=()):
|
|
101
|
+
for d in [os.path.expanduser(x) for x in extra] + search_dirs():
|
|
102
|
+
f = os.path.join(d, binary)
|
|
103
|
+
if os.path.isfile(f) and os.access(f, os.X_OK):
|
|
104
|
+
return f
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _run(args, timeout, **kw):
|
|
109
|
+
return subprocess.run(args, capture_output=True, text=True, timeout=timeout, env=child_env(), stdin=subprocess.DEVNULL, **kw)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _help_levels(path, flag, pattern):
|
|
113
|
+
"""Effort or thinking levels as the CLI's own --help lists them."""
|
|
114
|
+
try:
|
|
115
|
+
text = _run([path, "--help"], 8).stdout
|
|
116
|
+
except Exception:
|
|
117
|
+
return []
|
|
118
|
+
m = re.search(pattern, text, re.S)
|
|
119
|
+
return [x.strip() for x in m.group(1).split(",") if x.strip()] if m else []
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── catalogs ──────────────────────────────────────────────────────────
|
|
123
|
+
def _claude_models(path):
|
|
124
|
+
efforts = _help_levels(path, "--effort", r"--effort <level>.*?\(([^)]*)\)")
|
|
125
|
+
efforts = (["off"] + efforts) if efforts else [] # off: no thinking at all (llm.py sets the thinking budget to nothing)
|
|
126
|
+
# (the user's own effortLevel is not the default here: llm.py leaves the user settings out of a call)
|
|
127
|
+
return [{"id": f"claude/claude-code/{a}", "name": f"Claude Code · {a.capitalize()}", "runtime": "claude-code",
|
|
128
|
+
"model": a, "efforts": efforts, "defaultEffort": None}
|
|
129
|
+
for a in ["fable", "opus", "sonnet", "haiku"]]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _rpc(args, requests, timeout=30, cwd=None):
|
|
133
|
+
"""Write JSON lines, collect the replies whose id we asked for."""
|
|
134
|
+
p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
|
|
135
|
+
env=child_env(), cwd=cwd or str(HOME))
|
|
136
|
+
got = {}
|
|
137
|
+
try:
|
|
138
|
+
for r in requests:
|
|
139
|
+
p.stdin.write(json.dumps(r) + "\n")
|
|
140
|
+
p.stdin.flush()
|
|
141
|
+
want = {r["id"] for r in requests if "id" in r}
|
|
142
|
+
t0 = time.time()
|
|
143
|
+
while want - set(got) and time.time() - t0 < timeout:
|
|
144
|
+
ready, _, _ = select.select([p.stdout], [], [], 0.5)
|
|
145
|
+
if not ready:
|
|
146
|
+
continue
|
|
147
|
+
line = p.stdout.readline()
|
|
148
|
+
if not line:
|
|
149
|
+
break
|
|
150
|
+
try:
|
|
151
|
+
d = json.loads(line)
|
|
152
|
+
except ValueError:
|
|
153
|
+
continue
|
|
154
|
+
if d.get("id") in want:
|
|
155
|
+
got[d["id"]] = d
|
|
156
|
+
finally:
|
|
157
|
+
p.kill()
|
|
158
|
+
return got
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _codex_models(path):
|
|
162
|
+
got = _rpc([path, "app-server"], [
|
|
163
|
+
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
164
|
+
"params": {"clientInfo": {"name": "textfold", "title": "TextFold", "version": "0.4"}, "capabilities": None}},
|
|
165
|
+
{"jsonrpc": "2.0", "method": "initialized"},
|
|
166
|
+
{"jsonrpc": "2.0", "id": 2, "method": "model/list", "params": {}}])
|
|
167
|
+
rows = (got.get(2) or {}).get("result", {}).get("data", [])
|
|
168
|
+
home = Path(os.environ.get("CODEX_HOME", HOME / ".codex"))
|
|
169
|
+
m = re.search(r'^model_reasoning_effort\s*=\s*"([^"]+)"', (home / "config.toml").read_text(), re.M) if (home / "config.toml").exists() else None
|
|
170
|
+
out = []
|
|
171
|
+
for r in rows:
|
|
172
|
+
if r.get("hidden"):
|
|
173
|
+
continue
|
|
174
|
+
mid = r.get("model") or r.get("id")
|
|
175
|
+
efforts = [e.get("reasoningEffort") for e in r.get("supportedReasoningEfforts", []) if e.get("reasoningEffort")]
|
|
176
|
+
default = m.group(1) if m and m.group(1) in efforts else r.get("defaultReasoningEffort")
|
|
177
|
+
out.append({"id": f"codex/codex/{mid}", "name": f"Codex · {r.get('displayName') or mid}", "runtime": "codex", "model": mid,
|
|
178
|
+
"efforts": efforts, "defaultEffort": default, "isDefault": bool(r.get("isDefault"))})
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _pi_models(path):
|
|
183
|
+
levels = _help_levels(path, "--thinking", r"--thinking <level>\s+Set thinking level:\s*([^\n]*)")
|
|
184
|
+
got = _rpc([path, "--mode", "rpc", "--no-session"], [{"id": "1", "type": "get_available_models"}])
|
|
185
|
+
data = (got.get("1") or {}).get("data")
|
|
186
|
+
rows = data.get("models", []) if isinstance(data, dict) else (data or [])
|
|
187
|
+
return [{"id": f"pi/{r['provider']}/{r['id']}", "name": f"Pi · {r.get('name') or r['id']}", "runtime": "pi",
|
|
188
|
+
"model": f"{r['provider']}/{r['id']}", "efforts": levels if r.get("reasoning") else [], "defaultEffort": None,
|
|
189
|
+
"vision": "image" in (r.get("input") or [])} for r in rows if r.get("provider") and r.get("id")]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
CATALOG = {"claude-code": _claude_models, "codex": _codex_models, "pi": _pi_models}
|
|
193
|
+
|
|
194
|
+
# ── the scan ──────────────────────────────────────────────────────────
|
|
195
|
+
STATE = {"scanned_at": None, "scanning": False, "agents": []}
|
|
196
|
+
_lock = threading.Lock()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def scan(log=None):
|
|
200
|
+
"""Find every registered agent, ask the runnable ones for their models. Safe to call from any thread."""
|
|
201
|
+
global _login_path
|
|
202
|
+
with _lock:
|
|
203
|
+
STATE["scanning"] = True
|
|
204
|
+
_login_path = None # a new install may have changed the login PATH
|
|
205
|
+
found, seen = [], {}
|
|
206
|
+
for runtime, label, binary, extra, runnable in REGISTRY:
|
|
207
|
+
# TEXTFOLD_NO_MODELS=1: none looked for (a test server, a CI run: nothing calls a model by accident)
|
|
208
|
+
path = None if os.environ.get("TEXTFOLD_NO_MODELS") else locate(binary, extra)
|
|
209
|
+
row = {"runtime": runtime, "label": label, "binary": binary, "path": path, "runnable": runnable,
|
|
210
|
+
"version": None, "models": [], "error": None}
|
|
211
|
+
if path:
|
|
212
|
+
real = os.path.realpath(path)
|
|
213
|
+
if real in seen: # one program under two names counts once
|
|
214
|
+
row.update(path=None, error=f"same program as {seen[real]}")
|
|
215
|
+
else:
|
|
216
|
+
seen[real] = label
|
|
217
|
+
try:
|
|
218
|
+
out = _run([path, "--version"], 8)
|
|
219
|
+
row["version"] = ((out.stdout or out.stderr).strip().splitlines() or [""])[0][:80]
|
|
220
|
+
except Exception as e:
|
|
221
|
+
row["error"] = str(e)[:200]
|
|
222
|
+
if runnable:
|
|
223
|
+
try:
|
|
224
|
+
row["models"] = CATALOG[runtime](path)
|
|
225
|
+
except Exception as e:
|
|
226
|
+
row["error"] = f"model list: {e}"[:200]
|
|
227
|
+
found.append(row)
|
|
228
|
+
STATE.update(agents=found, scanned_at=time.time(), scanning=False)
|
|
229
|
+
if log:
|
|
230
|
+
have = [f"{a['label']} {a['version'] or ''}".strip() + (f" ({len(a['models'])} models)" if a["models"] else "")
|
|
231
|
+
for a in found if a["path"]]
|
|
232
|
+
log("Agents found: " + (", ".join(have) or "none"))
|
|
233
|
+
return STATE
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def models():
|
|
237
|
+
return [m for a in STATE["agents"] if a["path"] and a["runnable"] for m in a["models"]]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def runtime_of(model_id):
|
|
241
|
+
return next((rt for rt, p in PREFIX.items() if model_id.startswith(p)), None)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def path_of(runtime):
|
|
245
|
+
a = next((a for a in STATE["agents"] if a["runtime"] == runtime and a["path"]), None)
|
|
246
|
+
if a:
|
|
247
|
+
return a["path"]
|
|
248
|
+
binary, extra = next((r[2], r[3]) for r in REGISTRY if r[0] == runtime)
|
|
249
|
+
return locate(binary, extra)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── one-shot runs ─────────────────────────────────────────────────────
|
|
253
|
+
def _strict(schema):
|
|
254
|
+
"""OpenAI-style structured output wants every object closed and every property required."""
|
|
255
|
+
if isinstance(schema, dict):
|
|
256
|
+
s = {k: _strict(v) for k, v in schema.items()}
|
|
257
|
+
if s.get("type") == "object" and "properties" in s:
|
|
258
|
+
s["additionalProperties"] = False
|
|
259
|
+
s["required"] = list(s["properties"])
|
|
260
|
+
return s
|
|
261
|
+
if isinstance(schema, list):
|
|
262
|
+
return [_strict(x) for x in schema]
|
|
263
|
+
return schema
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def parse_json(text, schema=None):
|
|
267
|
+
"""The reply's JSON object, asked for in the prompt: the first object that parses (and has the schema's required
|
|
268
|
+
keys), wherever it starts. A model that is not reasoning sometimes adds a line after the object, or repeats it."""
|
|
269
|
+
text, dec = text or "", json.JSONDecoder()
|
|
270
|
+
need = set((schema or {}).get("required", []))
|
|
271
|
+
first = None
|
|
272
|
+
for m in re.finditer(r"\{", text):
|
|
273
|
+
try:
|
|
274
|
+
obj, _ = dec.raw_decode(text, m.start())
|
|
275
|
+
except ValueError:
|
|
276
|
+
continue
|
|
277
|
+
if isinstance(obj, dict) and need <= set(obj):
|
|
278
|
+
return obj
|
|
279
|
+
first = first if first is not None else obj
|
|
280
|
+
if first is not None and not need:
|
|
281
|
+
return first
|
|
282
|
+
raise RuntimeError("no JSON in reply" if "{" not in text else "the reply's JSON does not parse")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run_codex(model, prompt, schema, system, effort, timeout):
|
|
286
|
+
path = path_of("codex")
|
|
287
|
+
if not path:
|
|
288
|
+
raise RuntimeError("Codex was not found on this computer")
|
|
289
|
+
with tempfile.TemporaryDirectory() as tmp: # a scratch cwd: no repository, nothing to edit
|
|
290
|
+
sf, of = Path(tmp) / "schema.json", Path(tmp) / "out.txt"
|
|
291
|
+
sf.write_text(json.dumps(_strict(schema)))
|
|
292
|
+
args = [path, "exec", "--ephemeral", "--skip-git-repo-check", "-s", "read-only", "-m", model,
|
|
293
|
+
"--output-schema", str(sf), "-o", str(of)]
|
|
294
|
+
# off is "none" (gpt-5.5 takes it, and refuses "minimal"); a model that refuses "none" runs at "low"
|
|
295
|
+
for level in (["none", "low"] if effort == "off" else [effort]):
|
|
296
|
+
r = subprocess.run(args + (["-c", f"model_reasoning_effort={level}"] if level else []) + ["-"], input=f"{system}\n\n{prompt}",
|
|
297
|
+
capture_output=True, text=True, timeout=timeout, env=child_env(), cwd=tmp)
|
|
298
|
+
if not r.returncode and of.exists():
|
|
299
|
+
break
|
|
300
|
+
if r.returncode or not of.exists():
|
|
301
|
+
raise RuntimeError(f"codex exec exited {r.returncode}: {(r.stderr or r.stdout)[-800:]}")
|
|
302
|
+
return parse_json(of.read_text()), None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def run_pi(model, prompt, schema, system, effort, timeout):
|
|
306
|
+
path = path_of("pi")
|
|
307
|
+
if not path:
|
|
308
|
+
raise RuntimeError("Pi was not found on this computer")
|
|
309
|
+
args = [path, "-p", "--mode", "json", "--no-tools", "--no-session", "--no-context-files", "--no-extensions", "--no-skills",
|
|
310
|
+
"--no-prompt-templates", "--model", model, "--system-prompt", system]
|
|
311
|
+
if effort:
|
|
312
|
+
args += ["--thinking", effort]
|
|
313
|
+
body = f"{prompt}\n\nReply with one JSON object that matches this JSON Schema, and nothing else:\n{json.dumps(schema)}"
|
|
314
|
+
for attempt in (1, 2): # a reply whose JSON does not parse is asked for once more (it is a sample: the next one parses)
|
|
315
|
+
try:
|
|
316
|
+
return _pi_once(args, body, schema, timeout)
|
|
317
|
+
except ValueError:
|
|
318
|
+
if attempt == 2:
|
|
319
|
+
raise
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _pi_once(args, body, schema, timeout):
|
|
323
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
324
|
+
r = subprocess.run(args, input=body, capture_output=True, text=True, timeout=timeout, env=child_env(), cwd=tmp)
|
|
325
|
+
text, cost = None, None
|
|
326
|
+
for line in r.stdout.splitlines():
|
|
327
|
+
try:
|
|
328
|
+
d = json.loads(line)
|
|
329
|
+
except ValueError:
|
|
330
|
+
continue
|
|
331
|
+
if d.get("type") == "agent_end":
|
|
332
|
+
last = next((m for m in reversed(d.get("messages", [])) if m.get("role") == "assistant"), None)
|
|
333
|
+
if last:
|
|
334
|
+
text = "".join(c.get("text", "") for c in last.get("content", []) if c.get("type") == "text")
|
|
335
|
+
cost = ((last.get("usage") or {}).get("cost") or {}).get("total")
|
|
336
|
+
if last.get("stopReason") == "error" or last.get("errorMessage"):
|
|
337
|
+
raise RuntimeError(f"pi: {last.get('errorMessage') or 'error'}")
|
|
338
|
+
if text is None:
|
|
339
|
+
raise RuntimeError(f"pi exited {r.returncode}: {(r.stderr or r.stdout)[-800:]}")
|
|
340
|
+
try:
|
|
341
|
+
return parse_json(text, schema), cost
|
|
342
|
+
except RuntimeError as e:
|
|
343
|
+
raise ValueError(str(e))
|
|
344
|
+
|
textfold/biorxiv.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Preprints from bioRxiv and medRxiv, read from their own JATS XML.
|
|
2
|
+
|
|
3
|
+
Europe PMC holds the full text of only some of them (about 20,000 of bioRxiv's 350,000), so a preprint it does not
|
|
4
|
+
hold is read from the server itself: its API (api.biorxiv.org) gives each version's XML. The newest version's XML
|
|
5
|
+
can be the abstract alone while its full text is being prepared; then the newest one with a body is read.
|
|
6
|
+
Their XML draws tables and display formulas as pictures, which the page shows as such.
|
|
7
|
+
|
|
8
|
+
10.1101/2024.01.15.575678 · 10.64898/2026.09.24.754278 (openRxiv's prefix since 2026)
|
|
9
|
+
https://www.biorxiv.org/content/10.64898/2026.09.24.754278v1 · the same on medrxiv.org
|
|
10
|
+
|
|
11
|
+
Stored as papers/biorxiv-<suffix>/ or papers/medrxiv-<suffix>/. A request for the XML and one for each image, with a
|
|
12
|
+
User-Agent of its own (the servers turn away the generic ones), and seven seconds apart, as their robots.txt asks
|
|
13
|
+
(Crawl-delay: 7); faster, their firewall shuts the door for a while. A paper with many pictures takes a minute or two.
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
|
|
23
|
+
from . import epmc, jats, local, store
|
|
24
|
+
from .fetch import UA
|
|
25
|
+
|
|
26
|
+
API = "https://api.biorxiv.org/details/{server}/{doi}"
|
|
27
|
+
SERVERS = ("biorxiv", "medrxiv")
|
|
28
|
+
PREFIXES = ("10.1101", "10.64898")
|
|
29
|
+
DOI = re.compile(r"\b(10\.(?:1101|64898)/[\w.]+?)(?:v\d+)?(?:\.full(?:-text|\.pdf)?|\.abstract|\.supplementary-material)?(?=$|[/?#\s])")
|
|
30
|
+
DELAY = 7 # seconds between requests to www.biorxiv.org and www.medrxiv.org (their robots.txt)
|
|
31
|
+
_pace = {"last": 0.0, "lock": threading.Lock()}
|
|
32
|
+
LICENSES = {"cc_by": "by/4.0", "cc_by_nc": "by-nc/4.0", "cc_by_nd": "by-nd/4.0", "cc_by_nc_nd": "by-nc-nd/4.0"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ref_of(ref):
|
|
36
|
+
"""(server or None, DOI) for a bioRxiv or medRxiv link or DOI; (None, None) for anything else."""
|
|
37
|
+
ref = urllib.parse.unquote((ref or "").strip())
|
|
38
|
+
m = DOI.search(ref)
|
|
39
|
+
if not m:
|
|
40
|
+
return None, None
|
|
41
|
+
host = re.search(r"(biorxiv|medrxiv)\.org", ref, re.I)
|
|
42
|
+
return (host.group(1).lower() if host else None), m.group(1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pid_of(server, doi):
|
|
46
|
+
return f"{server}-{doi.split('/', 1)[1]}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get(url, timeout=60):
|
|
50
|
+
if re.match(r"https?://(www\.)?(biorxiv|medrxiv)\.org/", url):
|
|
51
|
+
with _pace["lock"]:
|
|
52
|
+
time.sleep(max(0.0, _pace["last"] + DELAY - time.time()))
|
|
53
|
+
_pace["last"] = time.time()
|
|
54
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=timeout) as r:
|
|
55
|
+
return r.read(), r.geturl()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def versions(server, doi):
|
|
59
|
+
data, _ = _get(API.format(server=server, doi=doi), 30)
|
|
60
|
+
return sorted(json.loads(data).get("collection") or [], key=lambda r: int(r.get("version") or 0))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def find(doi, server=None):
|
|
64
|
+
"""The server that holds a DOI, and its versions; ValueError("no-fulltext") when neither does."""
|
|
65
|
+
for s in ([server] if server else SERVERS):
|
|
66
|
+
vs = versions(s, doi)
|
|
67
|
+
if vs:
|
|
68
|
+
return s, vs
|
|
69
|
+
raise ValueError("no-fulltext")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def image_resolver(content, folder, progress=None, total=0):
|
|
73
|
+
"""A figure's or table's "F1.large.jpg" beside the XML (…/content/biorxiv/early/<date>/<suffix>/); any other
|
|
74
|
+
picture ("embed/graphic-6.gif") where the site keeps them (…/sites/default/files/highwire/biorxiv/early/…)."""
|
|
75
|
+
images = local.Images(folder, None, get=_get)
|
|
76
|
+
|
|
77
|
+
def resolve(src):
|
|
78
|
+
if progress:
|
|
79
|
+
progress("fetch", len(images.seen), total)
|
|
80
|
+
if src.startswith("embed/"):
|
|
81
|
+
src = re.sub(r"/content/(biorxiv|medrxiv)/", r"/sites/default/files/highwire/\1/", content) + "/" + src
|
|
82
|
+
elif not re.match(r"https?://", src):
|
|
83
|
+
src = content + "/" + src
|
|
84
|
+
return images.resolve(src)
|
|
85
|
+
return resolve
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _from_europepmc(pid, server, rec):
|
|
89
|
+
"""The preprint as Europe PMC holds it (its full text converted from the PDF), stored under this preprint's id."""
|
|
90
|
+
name = "bioRxiv" if server == "biorxiv" else "medRxiv"
|
|
91
|
+
refused = RuntimeError(f"{name} refused this computer for now (its firewall), and Europe PMC holds no full text of this "
|
|
92
|
+
"preprint. Try again later.")
|
|
93
|
+
try:
|
|
94
|
+
eid = epmc.by_doi(rec["doi"])
|
|
95
|
+
return epmc.fetch(pid, eid, {"abs_url": f"https://www.{server}.org/content/{rec['doi']}v{rec.get('version', '')}",
|
|
96
|
+
"label": f"{name} {rec['doi']} · Europe PMC {eid.upper()}", "doi": rec["doi"]})
|
|
97
|
+
except (ValueError, RuntimeError): # no full text there either (some it lists are PDFs only, answered with a 500)
|
|
98
|
+
raise refused
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _has_body(xml):
|
|
102
|
+
"""The XML of a version whose full text is not ready yet holds the abstract and an empty body."""
|
|
103
|
+
m = re.search(r"<body\b[^>]*>(.*?)</body>", xml, re.S)
|
|
104
|
+
return bool(m) and (len(re.findall(r"<sec\b", m.group(1))) > 0 or len(re.findall(r"<p\b", m.group(1))) >= 3)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fetch(pid, progress=None):
|
|
108
|
+
"""Store a preprint from bioRxiv or medRxiv (once). Its id: biorxiv-… or medrxiv-…. progress(stage, done, total)
|
|
109
|
+
follows the images."""
|
|
110
|
+
d = store.pdir(pid)
|
|
111
|
+
if (d / "meta.json").exists() and (d / "source.html").exists():
|
|
112
|
+
return pid
|
|
113
|
+
server, suffix = pid.split("-", 1)
|
|
114
|
+
found = None
|
|
115
|
+
for prefix in PREFIXES:
|
|
116
|
+
vs = versions(server, f"{prefix}/{suffix}")
|
|
117
|
+
if vs:
|
|
118
|
+
found = vs
|
|
119
|
+
break
|
|
120
|
+
if not found:
|
|
121
|
+
raise RuntimeError("no-fulltext")
|
|
122
|
+
for rec in reversed(found): # the newest version whose XML has a body
|
|
123
|
+
if not rec.get("jatsxml"):
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
xml, final = _get(rec["jatsxml"])
|
|
127
|
+
except urllib.error.HTTPError as e:
|
|
128
|
+
if e.code == 403: # the site's firewall turned this computer away: Europe PMC's copy, if it holds one
|
|
129
|
+
return _from_europepmc(pid, server, found[-1])
|
|
130
|
+
continue
|
|
131
|
+
xml = xml.decode("utf-8", "replace")
|
|
132
|
+
if _has_body(xml):
|
|
133
|
+
break
|
|
134
|
+
else:
|
|
135
|
+
raise RuntimeError("no-fulltext")
|
|
136
|
+
html, m = jats.to_html(xml)
|
|
137
|
+
lic = LICENSES.get(rec.get("license", ""))
|
|
138
|
+
name = "bioRxiv" if server == "biorxiv" else "medRxiv"
|
|
139
|
+
meta = {"id": pid, "version": f"v{rec.get('version', '')}", "title": m["title"] or rec.get("title", ""),
|
|
140
|
+
"authors": m["authors"] or [a.strip() for a in (rec.get("authors") or "").split(";") if a.strip()],
|
|
141
|
+
"date": rec.get("date", ""), "doi": rec["doi"],
|
|
142
|
+
"abs_url": f"https://www.{server}.org/content/{rec['doi']}v{rec.get('version', '')}", "html_url": "",
|
|
143
|
+
"license": m["license"] or (f"https://creativecommons.org/licenses/{lic}/" if lic else ""),
|
|
144
|
+
"source": {"kind": server, "name": rec["doi"]}, "label": f"{name} {rec['doi']}v{rec.get('version', '')}"}
|
|
145
|
+
content = re.sub(r"\.source\.xml$", "", final)
|
|
146
|
+
total = len(set(re.findall(r'<img src="([^"]+)"', html)))
|
|
147
|
+
return local.store_doc(pid, html, meta, "source.xml", xml, lambda folder: image_resolver(content, folder, progress, total))
|