opencraw 0.0.2__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.
- opencraw/__init__.py +33 -0
- opencraw/build_recipe_algorithm.py +52 -0
- opencraw/hook_pending_error.py +12 -0
- opencraw/opencraw_cli_client.py +82 -0
- opencraw/opencraw_contract.py +36 -0
- opencraw/opencraw_error.py +2 -0
- opencraw/py.typed +0 -0
- opencraw/serve_hook_use_case.py +40 -0
- opencraw/write_recipes_use_case.py +24 -0
- opencraw-0.0.2.dist-info/METADATA +98 -0
- opencraw-0.0.2.dist-info/RECORD +12 -0
- opencraw-0.0.2.dist-info/WHEEL +4 -0
opencraw/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Build OpenCraw recipes in Python, drive the real CLI, and write hooks. See README.md."""
|
|
2
|
+
|
|
3
|
+
from .build_recipe_algorithm import input_recipe, output_recipe, step
|
|
4
|
+
from .hook_pending_error import HookPending
|
|
5
|
+
from .opencraw_cli_client import cli_command, run, validate
|
|
6
|
+
from .opencraw_contract import (
|
|
7
|
+
HookContext,
|
|
8
|
+
HookRequest,
|
|
9
|
+
Recipe,
|
|
10
|
+
RecipeSource,
|
|
11
|
+
Validation,
|
|
12
|
+
)
|
|
13
|
+
from .opencraw_error import OpenCrawError
|
|
14
|
+
from .serve_hook_use_case import serve_hook
|
|
15
|
+
from .write_recipes_use_case import write_recipes
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"HookContext",
|
|
19
|
+
"HookPending",
|
|
20
|
+
"HookRequest",
|
|
21
|
+
"OpenCrawError",
|
|
22
|
+
"Recipe",
|
|
23
|
+
"RecipeSource",
|
|
24
|
+
"Validation",
|
|
25
|
+
"cli_command",
|
|
26
|
+
"input_recipe",
|
|
27
|
+
"output_recipe",
|
|
28
|
+
"run",
|
|
29
|
+
"serve_hook",
|
|
30
|
+
"step",
|
|
31
|
+
"validate",
|
|
32
|
+
"write_recipes",
|
|
33
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Plain-dict constructors for recipes. They save typing and nothing more: the step and field shapes are
|
|
2
|
+
the engine's, so a key goes in exactly as the recipe guide spells it (``forEach``, ``inStock``) and the
|
|
3
|
+
engine, not this module, says whether it is valid."""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .opencraw_contract import Recipe
|
|
9
|
+
|
|
10
|
+
StartPoint = str | Mapping[str, Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def step(step_type: str, **fields: Any) -> Recipe:
|
|
14
|
+
"""One step: ``step('goto', url='{{start.url}}')`` is ``{'type': 'goto', 'url': '{{start.url}}'}``."""
|
|
15
|
+
return {"type": step_type, **fields}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def output_recipe(
|
|
19
|
+
recipe_id: str, fields: Mapping[str, Any], version: int = 1, **extra: Any
|
|
20
|
+
) -> Recipe:
|
|
21
|
+
"""An output recipe: the records' schema."""
|
|
22
|
+
return {
|
|
23
|
+
"kind": "output",
|
|
24
|
+
"id": recipe_id,
|
|
25
|
+
"version": version,
|
|
26
|
+
"fields": dict(fields),
|
|
27
|
+
**extra,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def input_recipe(
|
|
32
|
+
recipe_id: str,
|
|
33
|
+
output: str,
|
|
34
|
+
mode: str,
|
|
35
|
+
start: Sequence[StartPoint],
|
|
36
|
+
steps: Sequence[Mapping[str, Any]],
|
|
37
|
+
mapping: Mapping[str, Any] | None = None,
|
|
38
|
+
**extra: Any,
|
|
39
|
+
) -> Recipe:
|
|
40
|
+
"""An input recipe. A start given as a string is a URL: ``'https://a.example'`` is ``{'url': ...}``."""
|
|
41
|
+
return {
|
|
42
|
+
"kind": "input",
|
|
43
|
+
"id": recipe_id,
|
|
44
|
+
"output": output,
|
|
45
|
+
"mode": mode,
|
|
46
|
+
"start": [
|
|
47
|
+
{"url": point} if isinstance(point, str) else dict(point) for point in start
|
|
48
|
+
],
|
|
49
|
+
"steps": [dict(each) for each in steps],
|
|
50
|
+
"mapping": dict(mapping or {}),
|
|
51
|
+
**extra,
|
|
52
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class HookPending(Exception):
|
|
2
|
+
"""Raised by a hook handler whose work is not done yet.
|
|
3
|
+
|
|
4
|
+
``serve_hook`` turns it into a ``pending`` answer: OpenCraw runs the hook again, after
|
|
5
|
+
``retry_after_ms`` (a second when it is ``None``), with the same ``idempotencyKey``, until the
|
|
6
|
+
handler returns or the hook's ``maxWaitMs`` passes. Start the work the first time, and look it up
|
|
7
|
+
by ``request["idempotencyKey"]`` on the later calls.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, retry_after_ms: int | None = None) -> None:
|
|
11
|
+
super().__init__("pending")
|
|
12
|
+
self.retry_after_ms = retry_after_ms
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shlex
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .opencraw_contract import RecipeSource, Validation
|
|
12
|
+
from .opencraw_error import OpenCrawError
|
|
13
|
+
from .write_recipes_use_case import write_recipes
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cli_command() -> list[str]:
|
|
17
|
+
"""How to start the CLI: ``OPENCRAW_CLI`` if set, else ``opencraw`` on the PATH, else ``npx``."""
|
|
18
|
+
configured = os.environ.get("OPENCRAW_CLI")
|
|
19
|
+
if configured:
|
|
20
|
+
return shlex.split(configured)
|
|
21
|
+
installed = shutil.which("opencraw")
|
|
22
|
+
if installed:
|
|
23
|
+
return [installed]
|
|
24
|
+
npx = shutil.which("npx")
|
|
25
|
+
if npx is None:
|
|
26
|
+
raise OpenCrawError(
|
|
27
|
+
"opencraw is not installed: run `npm i -g @opencraw/cli` or set OPENCRAW_CLI"
|
|
28
|
+
)
|
|
29
|
+
return [npx, "--yes", "@opencraw/cli"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate(recipes: Sequence[RecipeSource]) -> Validation:
|
|
33
|
+
"""Runs ``opencraw validate``. The result is not an exception when the recipes are wrong: ``ok`` says so
|
|
34
|
+
and ``output`` lists every problem with its path."""
|
|
35
|
+
with _RecipePaths(recipes) as paths:
|
|
36
|
+
done = _call("validate", *paths)
|
|
37
|
+
return Validation(
|
|
38
|
+
ok=done.returncode == 0, output=(done.stdout + done.stderr).strip()
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run(recipes: Sequence[RecipeSource], *options: str) -> list[dict[str, Any]]:
|
|
43
|
+
"""Runs ``opencraw run`` and returns the records it printed as dicts. ``options`` are CLI flags, such as
|
|
44
|
+
``'--only', 'shop-a'``. Raises ``OpenCrawError`` with the CLI's message when the crawl fails."""
|
|
45
|
+
with _RecipePaths(recipes) as paths:
|
|
46
|
+
done = _call("run", *paths, *options)
|
|
47
|
+
if done.returncode != 0:
|
|
48
|
+
raise OpenCrawError((done.stderr or done.stdout).strip())
|
|
49
|
+
return [
|
|
50
|
+
json.loads(line)
|
|
51
|
+
for line in done.stdout.splitlines()
|
|
52
|
+
if line.strip().startswith("{")
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _RecipePaths:
|
|
57
|
+
"""Recipe paths for the CLI: dicts are written to a temporary folder that goes away afterwards."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, recipes: Sequence[RecipeSource]) -> None:
|
|
60
|
+
self._recipes = recipes
|
|
61
|
+
self._folder = tempfile.TemporaryDirectory(prefix="opencraw-")
|
|
62
|
+
|
|
63
|
+
def __enter__(self) -> list[str]:
|
|
64
|
+
paths = [str(item) for item in self._recipes if not isinstance(item, Mapping)]
|
|
65
|
+
objects = [item for item in self._recipes if isinstance(item, Mapping)]
|
|
66
|
+
if objects:
|
|
67
|
+
paths.append(str(Path(self._folder.name)))
|
|
68
|
+
write_recipes(self._folder.name, objects)
|
|
69
|
+
return paths
|
|
70
|
+
|
|
71
|
+
def __exit__(self, *_: object) -> None:
|
|
72
|
+
self._folder.cleanup()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _call(*arguments: str) -> subprocess.CompletedProcess[str]:
|
|
76
|
+
return subprocess.run(
|
|
77
|
+
[*cli_command(), *arguments],
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
encoding="utf-8",
|
|
81
|
+
check=False,
|
|
82
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, NotRequired, TypedDict
|
|
5
|
+
|
|
6
|
+
# A recipe as a plain dict, in the engine's own shape.
|
|
7
|
+
Recipe = dict[str, Any]
|
|
8
|
+
|
|
9
|
+
# A recipe given to validate() or run(): a file or folder path, or the recipe as a dict.
|
|
10
|
+
RecipeSource = str | Path | Mapping[str, Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HookContext(TypedDict):
|
|
14
|
+
"""Where a hook runs: the recipe, and the extracted values visible at the call site."""
|
|
15
|
+
|
|
16
|
+
recipeId: str
|
|
17
|
+
scope: dict[str, Any]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HookRequest(TypedDict):
|
|
21
|
+
"""The request OpenCraw sends a hook (``schemas/callout-request.schema.json``)."""
|
|
22
|
+
|
|
23
|
+
kind: str
|
|
24
|
+
name: str
|
|
25
|
+
input: NotRequired[Any]
|
|
26
|
+
args: dict[str, Any]
|
|
27
|
+
context: HookContext
|
|
28
|
+
idempotencyKey: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Validation:
|
|
33
|
+
"""What ``opencraw validate`` said: ``ok``, and ``output`` listing every problem with its path."""
|
|
34
|
+
|
|
35
|
+
ok: bool
|
|
36
|
+
output: str
|
opencraw/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, TextIO
|
|
5
|
+
|
|
6
|
+
from .hook_pending_error import HookPending
|
|
7
|
+
from .opencraw_contract import HookRequest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def serve_hook(
|
|
11
|
+
handler: Callable[[Any, dict[str, Any], HookRequest], Any],
|
|
12
|
+
stdin: TextIO | None = None,
|
|
13
|
+
stdout: TextIO | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Answer one OpenCraw hook call: read the request, run ``handler``, write the answer.
|
|
16
|
+
|
|
17
|
+
``handler(input, args, request)`` gets the value a hook transform is working on (``None`` for a
|
|
18
|
+
``hook`` step), the recipe's arguments, and the whole request (``context``, ``idempotencyKey``).
|
|
19
|
+
What it returns is the output, and an exception becomes an ``error`` answer with its message, so
|
|
20
|
+
the step fails with that reason instead of a stack trace. Raising ``HookPending`` answers
|
|
21
|
+
``pending`` instead, and OpenCraw asks again later. Use it as the whole of a hook script,
|
|
22
|
+
the program ``commandHook`` runs::
|
|
23
|
+
|
|
24
|
+
from opencraw import serve_hook
|
|
25
|
+
|
|
26
|
+
serve_hook(lambda value, args, request: str(value).strip().lower())
|
|
27
|
+
"""
|
|
28
|
+
request: HookRequest = json.load(stdin or sys.stdin)
|
|
29
|
+
try:
|
|
30
|
+
answer: dict[str, Any] = {
|
|
31
|
+
"status": "ok",
|
|
32
|
+
"output": handler(request.get("input"), request.get("args", {}), request),
|
|
33
|
+
}
|
|
34
|
+
except HookPending as pending:
|
|
35
|
+
answer = {"status": "pending"}
|
|
36
|
+
if pending.retry_after_ms is not None:
|
|
37
|
+
answer["retryAfterMs"] = pending.retry_after_ms
|
|
38
|
+
except Exception as error: # noqa: BLE001 - whatever the handler raises is the hook's answer
|
|
39
|
+
answer = {"status": "error", "error": str(error) or type(error).__name__}
|
|
40
|
+
json.dump(answer, stdout or sys.stdout)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Iterable, Mapping
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def write_recipes(
|
|
8
|
+
folder: str | Path, recipes: Iterable[Mapping[str, Any]]
|
|
9
|
+
) -> list[Path]:
|
|
10
|
+
"""Writes each recipe as ``<id>.input.json`` or ``<id>.output.json``, the layout the CLI, the Studio
|
|
11
|
+
and the MCP server read. The folder is created when missing. Returns the files written."""
|
|
12
|
+
target = Path(folder)
|
|
13
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
written = []
|
|
15
|
+
for recipe in recipes:
|
|
16
|
+
kind = recipe.get("kind")
|
|
17
|
+
if kind not in ("input", "output") or not isinstance(recipe.get("id"), str):
|
|
18
|
+
raise ValueError(
|
|
19
|
+
f"a recipe needs a kind of 'input' or 'output' and a string id, got {dict(recipe)!r}"
|
|
20
|
+
)
|
|
21
|
+
path = target / f"{recipe['id']}.{kind}.json"
|
|
22
|
+
path.write_text(json.dumps(recipe, indent=2) + "\n", encoding="utf-8")
|
|
23
|
+
written.append(path)
|
|
24
|
+
return written
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: opencraw
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Build OpenCraw recipes in Python, drive the real OpenCraw CLI, and write hooks in Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Open-Craw/OpenCraw
|
|
6
|
+
Project-URL: Repository, https://github.com/Open-Craw/OpenCraw
|
|
7
|
+
Project-URL: Issues, https://github.com/Open-Craw/OpenCraw/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: crawler,opencraw,recipes,scraping
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# opencraw (Python)
|
|
14
|
+
|
|
15
|
+
Build [OpenCraw](https://github.com/Open-Craw/OpenCraw) recipes in Python, and drive the real CLI. Standard library only; it
|
|
16
|
+
needs Python 3.12+ and, to validate or run, the `opencraw` CLI (`npm i -g @opencraw/cli`, or
|
|
17
|
+
`OPENCRAW_CLI` set to the command that starts it; with neither, `npx @opencraw/cli` is used).
|
|
18
|
+
|
|
19
|
+
A recipe stays a plain dict in the engine's own shape, so the recipe guide
|
|
20
|
+
([docs/recipes/authoring.md](https://github.com/Open-Craw/OpenCraw/blob/main/docs/recipes/authoring.md)) is the reference for every key. The constructors only save typing, and
|
|
21
|
+
the engine, not this package, says what is valid.
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from opencraw import input_recipe, output_recipe, run, step, validate
|
|
25
|
+
|
|
26
|
+
product = output_recipe(
|
|
27
|
+
"product",
|
|
28
|
+
{
|
|
29
|
+
"url": {"type": "url", "required": True, "key": True},
|
|
30
|
+
"title": {"type": "string", "required": True},
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
shops = [
|
|
35
|
+
input_recipe(
|
|
36
|
+
f"shop-{name}",
|
|
37
|
+
"product",
|
|
38
|
+
"web",
|
|
39
|
+
[url],
|
|
40
|
+
[
|
|
41
|
+
step("goto", url="{{start.url}}"),
|
|
42
|
+
step("extract", id="title", selector=selector, kind="css", take="text"),
|
|
43
|
+
step("emit"),
|
|
44
|
+
],
|
|
45
|
+
{"url": {"from": "start.url"}, "title": {"from": "title"}},
|
|
46
|
+
)
|
|
47
|
+
for name, url, selector in [
|
|
48
|
+
("a", "https://a.example", "h1"),
|
|
49
|
+
("b", "https://b.example", ".name"),
|
|
50
|
+
]
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
result = validate(
|
|
54
|
+
[product, *shops]
|
|
55
|
+
) # Validation(ok, output): problems are data, not exceptions
|
|
56
|
+
records = run(
|
|
57
|
+
[product, *shops], "--only", "shop-a"
|
|
58
|
+
) # records as dicts; OpenCrawError if the crawl fails
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Reserved words: pass a step's `as` or `from` through `**{"as": "json"}`.
|
|
62
|
+
|
|
63
|
+
`write_recipes(folder, recipes)` writes `<id>.input.json` and `<id>.output.json` for the CLI, the Studio
|
|
64
|
+
and the MCP server.
|
|
65
|
+
|
|
66
|
+
## A hook written in Python
|
|
67
|
+
|
|
68
|
+
A recipe's hook (`{ "op": "hook", "name": "slug" }`) can be a Python script. The hooks module registers it
|
|
69
|
+
with `commandHook` (`@opencraw/core`), and the script is a few lines:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# hooks/slug.py
|
|
73
|
+
import re
|
|
74
|
+
from opencraw import serve_hook
|
|
75
|
+
|
|
76
|
+
serve_hook(
|
|
77
|
+
lambda value, args, request: re.sub(r"[^a-z0-9]+", "-", str(value).lower()).strip(
|
|
78
|
+
"-"
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`serve_hook(handler)` reads the request from stdin, calls `handler(input, args, request)` (`input` is `None`
|
|
84
|
+
for a `hook` step; `request` carries `context` and `idempotencyKey`) and writes the answer to stdout. What the
|
|
85
|
+
handler returns is the output; an exception becomes an error answer with its message, so the step fails with
|
|
86
|
+
that reason. A handler whose work takes longer raises `HookPending(retry_after_ms)`: OpenCraw runs the script
|
|
87
|
+
again after that wait, with the same `idempotencyKey`, until it returns or the hook's `maxWaitMs` passes, so the
|
|
88
|
+
script starts the work once and looks it up by that key on later runs. The request and answer shapes are in
|
|
89
|
+
`packages/core/schemas/callout-*.schema.json`.
|
|
90
|
+
|
|
91
|
+
Not in this package: a Python engine and generated typed models. The crawl itself always runs in the
|
|
92
|
+
OpenCraw CLI. It is published to PyPI as `opencraw`: `mnci ci release` versions and publishes what sits
|
|
93
|
+
under `python-packages/`.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
`nx test opencraw` (pytest; the CLI integration tests use this repository's built CLI and skip without it),
|
|
98
|
+
`nx lint opencraw` (Ruff), `nx typecheck opencraw` (mypy strict).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
opencraw/__init__.py,sha256=N6weBp2Nm2SU3BB-5np0A6xirQpzThEaxupyMfJZSq8,801
|
|
2
|
+
opencraw/build_recipe_algorithm.py,sha256=813fbozrIemt9fRhH-GpU2k47qnEieCiPyzKsuYPQeA,1591
|
|
3
|
+
opencraw/hook_pending_error.py,sha256=Dqntt243wcYetgSQWkbDuuLC9HUpKAtZz9FywVtPT4g,597
|
|
4
|
+
opencraw/opencraw_cli_client.py,sha256=mctjvpYOlggUonCI-nnmvgQ-ytE4UATeUE1Y3abR45c,2854
|
|
5
|
+
opencraw/opencraw_contract.py,sha256=kbHc2QFEx041jR-Zm4KkoI6e8_vTytS0FSeTftW9oAo,954
|
|
6
|
+
opencraw/opencraw_error.py,sha256=MsoOkViFtUauCzJn8uDhM1EHoPusPFbbYK4dX6Z1y38,107
|
|
7
|
+
opencraw/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
opencraw/serve_hook_use_case.py,sha256=Z0uL4vacBWcA0Y60Y8RbkyzO4UpZ6Uuk1ZmOPFm03UQ,1717
|
|
9
|
+
opencraw/write_recipes_use_case.py,sha256=rxV7KFR0elcqNAj8KKXaw8hEP2g7Mg08vI2hMRONyWA,967
|
|
10
|
+
opencraw-0.0.2.dist-info/METADATA,sha256=DCsdSrSpuGqD5xhq3xi-WKPogmKqr0VoTO9ORq_htuI,3800
|
|
11
|
+
opencraw-0.0.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
opencraw-0.0.2.dist-info/RECORD,,
|