public-source-extractor 0.1.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.
- public_source_extractor/__init__.py +3 -0
- public_source_extractor/__main__.py +5 -0
- public_source_extractor/cli.py +61 -0
- public_source_extractor/errors.py +58 -0
- public_source_extractor/output.py +123 -0
- public_source_extractor/provider.py +154 -0
- public_source_extractor/schemas/__init__.py +0 -0
- public_source_extractor/schemas/output-v0.1.schema.json +90 -0
- public_source_extractor/url_policy.py +171 -0
- public_source_extractor-0.1.0.dist-info/METADATA +82 -0
- public_source_extractor-0.1.0.dist-info/RECORD +15 -0
- public_source_extractor-0.1.0.dist-info/WHEEL +5 -0
- public_source_extractor-0.1.0.dist-info/entry_points.txt +2 -0
- public_source_extractor-0.1.0.dist-info/licenses/LICENSE +22 -0
- public_source_extractor-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Sequence
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .errors import ExtractorError, InputRejected
|
|
11
|
+
from .output import build_success_envelope, render_json, render_markdown, write_new_file_atomic
|
|
12
|
+
from .provider import FirecrawlKeylessProvider
|
|
13
|
+
from .url_policy import validate_public_url
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StableArgumentParser(argparse.ArgumentParser):
|
|
17
|
+
def error(self, _message: str) -> None:
|
|
18
|
+
raise InputRejected()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = StableArgumentParser(
|
|
23
|
+
prog="public-source-extractor",
|
|
24
|
+
description="Convert one public URL into Markdown or a stable JSON envelope.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("url", help="Public HTTP or HTTPS URL.")
|
|
27
|
+
parser.add_argument("--mode", choices=("markdown", "json"), default="markdown")
|
|
28
|
+
parser.add_argument("--output", type=Path, help="Write to a new file instead of stdout.")
|
|
29
|
+
parser.add_argument("--timeout", type=int, default=60, metavar="SECONDS")
|
|
30
|
+
parser.add_argument("--provider", choices=("firecrawl-keyless",), default="firecrawl-keyless")
|
|
31
|
+
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output.")
|
|
32
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
33
|
+
return parser
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run(argv: Sequence[str] | None = None) -> int:
|
|
37
|
+
try:
|
|
38
|
+
args = build_parser().parse_args(argv)
|
|
39
|
+
if not 1 <= args.timeout <= 120:
|
|
40
|
+
raise InputRejected()
|
|
41
|
+
validate_public_url(args.url)
|
|
42
|
+
provider = FirecrawlKeylessProvider()
|
|
43
|
+
result = provider.extract(args.url, args.mode, args.timeout)
|
|
44
|
+
envelope = build_success_envelope(args.url, args.mode, result)
|
|
45
|
+
rendered = (
|
|
46
|
+
render_markdown(envelope)
|
|
47
|
+
if args.mode == "markdown"
|
|
48
|
+
else render_json(envelope, args.pretty)
|
|
49
|
+
)
|
|
50
|
+
if args.output is None:
|
|
51
|
+
sys.stdout.write(rendered)
|
|
52
|
+
else:
|
|
53
|
+
write_new_file_atomic(args.output, rendered)
|
|
54
|
+
return 0
|
|
55
|
+
except ExtractorError as exc:
|
|
56
|
+
sys.stderr.write(json.dumps(exc.envelope(), ensure_ascii=False) + "\n")
|
|
57
|
+
return exc.exit_code
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main() -> int:
|
|
61
|
+
return run()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExtractorError(Exception):
|
|
5
|
+
code = "extractor_error"
|
|
6
|
+
exit_code = 1
|
|
7
|
+
retryable = False
|
|
8
|
+
public_message = "Extraction failed."
|
|
9
|
+
|
|
10
|
+
def __init__(self, message: str | None = None) -> None:
|
|
11
|
+
super().__init__(message or self.public_message)
|
|
12
|
+
|
|
13
|
+
def envelope(self) -> dict[str, object]:
|
|
14
|
+
return {
|
|
15
|
+
"schema_version": "0.1",
|
|
16
|
+
"ok": False,
|
|
17
|
+
"error": {
|
|
18
|
+
"code": self.code,
|
|
19
|
+
"message": self.public_message,
|
|
20
|
+
"retryable": self.retryable,
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InputRejected(ExtractorError):
|
|
26
|
+
code = "input_rejected"
|
|
27
|
+
exit_code = 2
|
|
28
|
+
public_message = "The URL was rejected by the public-source safety policy."
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProviderFailure(ExtractorError):
|
|
32
|
+
code = "provider_failure"
|
|
33
|
+
exit_code = 3
|
|
34
|
+
retryable = True
|
|
35
|
+
public_message = "The extraction provider is unavailable or rejected the request."
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProviderRateLimited(ProviderFailure):
|
|
39
|
+
code = "provider_rate_limited"
|
|
40
|
+
public_message = "The experimental provider rate limit was exceeded."
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ProviderTimeout(ProviderFailure):
|
|
44
|
+
code = "provider_timeout"
|
|
45
|
+
public_message = "The extraction provider timed out."
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InvalidProviderResponse(ExtractorError):
|
|
49
|
+
code = "invalid_provider_response"
|
|
50
|
+
exit_code = 4
|
|
51
|
+
public_message = "The extraction provider returned an invalid or unsafe response."
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class OutputFailure(ExtractorError):
|
|
55
|
+
code = "output_failure"
|
|
56
|
+
exit_code = 5
|
|
57
|
+
public_message = "The output file could not be written safely."
|
|
58
|
+
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import OutputFailure
|
|
11
|
+
from .provider import FirecrawlKeylessProvider, ProviderResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_success_envelope(
|
|
15
|
+
requested_url: str,
|
|
16
|
+
mode: str,
|
|
17
|
+
result: ProviderResult,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
warnings = [
|
|
20
|
+
{
|
|
21
|
+
"code": "experimental_provider",
|
|
22
|
+
"message": "Keyless availability and limits are not guaranteed.",
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
if result.resolved_url is None:
|
|
26
|
+
warnings.append(
|
|
27
|
+
{
|
|
28
|
+
"code": "resolved_url_unavailable",
|
|
29
|
+
"message": "The provider did not return redirect metadata for post-checking.",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
return {
|
|
33
|
+
"schema_version": "0.1",
|
|
34
|
+
"ok": True,
|
|
35
|
+
"source": {
|
|
36
|
+
"requested_url": requested_url,
|
|
37
|
+
"resolved_url": result.resolved_url,
|
|
38
|
+
"fetched_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
|
|
39
|
+
"+00:00", "Z"
|
|
40
|
+
),
|
|
41
|
+
},
|
|
42
|
+
"mode": mode,
|
|
43
|
+
"content": result.content,
|
|
44
|
+
"metadata": {
|
|
45
|
+
"title": result.title,
|
|
46
|
+
"description": result.description,
|
|
47
|
+
"content_type": result.content_type,
|
|
48
|
+
"source_http_status": result.source_http_status,
|
|
49
|
+
},
|
|
50
|
+
"provider": {
|
|
51
|
+
"name": FirecrawlKeylessProvider.name,
|
|
52
|
+
"access": FirecrawlKeylessProvider.access,
|
|
53
|
+
"http_status": result.provider_http_status,
|
|
54
|
+
"credits_used": result.credits_used,
|
|
55
|
+
"elapsed_ms": result.elapsed_ms,
|
|
56
|
+
},
|
|
57
|
+
"warnings": warnings,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_markdown(envelope: dict[str, Any]) -> str:
|
|
62
|
+
source = envelope["source"]
|
|
63
|
+
metadata = envelope["metadata"]
|
|
64
|
+
provider = envelope["provider"]
|
|
65
|
+
front_matter = {
|
|
66
|
+
"schema_version": envelope["schema_version"],
|
|
67
|
+
"source_url": source["requested_url"],
|
|
68
|
+
"resolved_url": source["resolved_url"],
|
|
69
|
+
"fetched_at": source["fetched_at"],
|
|
70
|
+
"title": metadata["title"],
|
|
71
|
+
"content_type": metadata["content_type"],
|
|
72
|
+
"source_http_status": metadata["source_http_status"],
|
|
73
|
+
"provider": provider["name"],
|
|
74
|
+
"provider_access": provider["access"],
|
|
75
|
+
"provider_credits_used": provider["credits_used"],
|
|
76
|
+
"provider_elapsed_ms": provider["elapsed_ms"],
|
|
77
|
+
}
|
|
78
|
+
lines = ["---"]
|
|
79
|
+
lines.extend(
|
|
80
|
+
f"{key}: {json.dumps(value, ensure_ascii=False)}" for key, value in front_matter.items()
|
|
81
|
+
)
|
|
82
|
+
lines.extend(["---", "", str(envelope["content"]), ""])
|
|
83
|
+
return "\n".join(lines)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def render_json(envelope: dict[str, Any], pretty: bool) -> str:
|
|
87
|
+
indent = 2 if pretty else None
|
|
88
|
+
return json.dumps(envelope, ensure_ascii=False, indent=indent, separators=None) + "\n"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def write_new_file_atomic(path: Path, content: str) -> None:
|
|
92
|
+
try:
|
|
93
|
+
parent = path.parent.resolve(strict=True)
|
|
94
|
+
_reject_symlink_components(path.parent)
|
|
95
|
+
if not parent.is_dir() or path.exists() or path.is_symlink():
|
|
96
|
+
raise OutputFailure()
|
|
97
|
+
|
|
98
|
+
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=parent)
|
|
99
|
+
temporary = Path(temporary_name)
|
|
100
|
+
try:
|
|
101
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
102
|
+
handle.write(content)
|
|
103
|
+
handle.flush()
|
|
104
|
+
os.fsync(handle.fileno())
|
|
105
|
+
os.link(temporary, path)
|
|
106
|
+
finally:
|
|
107
|
+
temporary.unlink(missing_ok=True)
|
|
108
|
+
except OutputFailure:
|
|
109
|
+
raise
|
|
110
|
+
except (OSError, RuntimeError) as exc:
|
|
111
|
+
raise OutputFailure() from exc
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _reject_symlink_components(path: Path) -> None:
|
|
115
|
+
# Inspect the caller-selected path without resolving it first. macOS exposes
|
|
116
|
+
# /tmp and /var as stable platform aliases, so only those two are exempt.
|
|
117
|
+
platform_aliases = {Path("/tmp"), Path("/var")}
|
|
118
|
+
absolute = path.absolute()
|
|
119
|
+
current = Path(absolute.anchor)
|
|
120
|
+
for component in absolute.parts[1:]:
|
|
121
|
+
current /= component
|
|
122
|
+
if current.is_symlink() and current not in platform_aliases:
|
|
123
|
+
raise OutputFailure()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .errors import (
|
|
13
|
+
InputRejected,
|
|
14
|
+
InvalidProviderResponse,
|
|
15
|
+
ProviderFailure,
|
|
16
|
+
ProviderRateLimited,
|
|
17
|
+
ProviderTimeout,
|
|
18
|
+
)
|
|
19
|
+
from .url_policy import validate_provider_resolved_url
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
FIRECRAWL_ENDPOINT = "https://api.firecrawl.dev/v2/scrape"
|
|
23
|
+
MAX_RESPONSE_BYTES = 10 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
STRUCTURED_SCHEMA: dict[str, Any] = {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"properties": {
|
|
28
|
+
"title": {"type": "string"},
|
|
29
|
+
"summary": {"type": "string"},
|
|
30
|
+
"main_argument": {"type": "string"},
|
|
31
|
+
"key_points": {"type": "array", "items": {"type": "string"}},
|
|
32
|
+
"external_sources": {"type": "array", "items": {"type": "string"}},
|
|
33
|
+
},
|
|
34
|
+
"required": ["title", "summary", "main_argument"],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ProviderResult:
|
|
40
|
+
content: str | dict[str, Any]
|
|
41
|
+
resolved_url: str | None
|
|
42
|
+
title: str | None
|
|
43
|
+
description: str | None
|
|
44
|
+
content_type: str | None
|
|
45
|
+
source_http_status: int | None
|
|
46
|
+
provider_http_status: int
|
|
47
|
+
credits_used: int | None
|
|
48
|
+
elapsed_ms: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ExtractionProvider(Protocol):
|
|
52
|
+
def extract(self, url: str, mode: str, timeout_seconds: int) -> ProviderResult: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FirecrawlKeylessProvider:
|
|
56
|
+
name = "firecrawl-keyless"
|
|
57
|
+
access = "experimental"
|
|
58
|
+
|
|
59
|
+
def extract(self, url: str, mode: str, timeout_seconds: int) -> ProviderResult:
|
|
60
|
+
formats: list[Any]
|
|
61
|
+
if mode == "markdown":
|
|
62
|
+
formats = ["markdown"]
|
|
63
|
+
else:
|
|
64
|
+
formats = [{"type": "json", "schema": STRUCTURED_SCHEMA}]
|
|
65
|
+
payload = {
|
|
66
|
+
"url": url,
|
|
67
|
+
"formats": formats,
|
|
68
|
+
"onlyMainContent": True,
|
|
69
|
+
"removeBase64Images": True,
|
|
70
|
+
"timeout": timeout_seconds * 1000,
|
|
71
|
+
}
|
|
72
|
+
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
|
|
73
|
+
request = urllib.request.Request(
|
|
74
|
+
FIRECRAWL_ENDPOINT,
|
|
75
|
+
data=body,
|
|
76
|
+
headers={
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
"User-Agent": f"public-source-extractor/{__version__}",
|
|
79
|
+
},
|
|
80
|
+
method="POST",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
started = time.monotonic()
|
|
84
|
+
try:
|
|
85
|
+
with urllib.request.urlopen(request, timeout=timeout_seconds + 5) as response:
|
|
86
|
+
status = response.status
|
|
87
|
+
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
|
88
|
+
except urllib.error.HTTPError as exc:
|
|
89
|
+
if exc.code == 429:
|
|
90
|
+
raise ProviderRateLimited() from exc
|
|
91
|
+
raise ProviderFailure() from exc
|
|
92
|
+
except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
|
|
93
|
+
reason = getattr(exc, "reason", None)
|
|
94
|
+
if isinstance(exc, (TimeoutError, socket.timeout)) or isinstance(
|
|
95
|
+
reason, (TimeoutError, socket.timeout)
|
|
96
|
+
):
|
|
97
|
+
raise ProviderTimeout() from exc
|
|
98
|
+
raise ProviderFailure() from exc
|
|
99
|
+
|
|
100
|
+
elapsed_ms = round((time.monotonic() - started) * 1000)
|
|
101
|
+
if len(raw) > MAX_RESPONSE_BYTES:
|
|
102
|
+
raise InvalidProviderResponse()
|
|
103
|
+
try:
|
|
104
|
+
document = json.loads(raw.decode("utf-8"))
|
|
105
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
106
|
+
raise InvalidProviderResponse() from exc
|
|
107
|
+
if status != 200 or not isinstance(document, dict) or document.get("success") is not True:
|
|
108
|
+
raise ProviderFailure()
|
|
109
|
+
|
|
110
|
+
data = document.get("data")
|
|
111
|
+
if not isinstance(data, dict):
|
|
112
|
+
raise InvalidProviderResponse()
|
|
113
|
+
metadata = data.get("metadata")
|
|
114
|
+
if metadata is None:
|
|
115
|
+
metadata = {}
|
|
116
|
+
if not isinstance(metadata, dict):
|
|
117
|
+
raise InvalidProviderResponse()
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
resolved_url = validate_provider_resolved_url(
|
|
121
|
+
metadata.get("sourceURL") or metadata.get("url")
|
|
122
|
+
)
|
|
123
|
+
except InputRejected as exc:
|
|
124
|
+
raise InvalidProviderResponse() from exc
|
|
125
|
+
content = data.get("markdown") if mode == "markdown" else data.get("json")
|
|
126
|
+
if mode == "markdown" and not isinstance(content, str):
|
|
127
|
+
raise InvalidProviderResponse()
|
|
128
|
+
if mode == "json" and not isinstance(content, dict):
|
|
129
|
+
raise InvalidProviderResponse()
|
|
130
|
+
|
|
131
|
+
source_status = metadata.get("statusCode")
|
|
132
|
+
if not isinstance(source_status, int) or isinstance(source_status, bool):
|
|
133
|
+
source_status = None
|
|
134
|
+
credits_used = metadata.get("creditsUsed")
|
|
135
|
+
if not isinstance(credits_used, int) or isinstance(credits_used, bool):
|
|
136
|
+
credits_used = None
|
|
137
|
+
|
|
138
|
+
return ProviderResult(
|
|
139
|
+
content=content,
|
|
140
|
+
resolved_url=resolved_url,
|
|
141
|
+
title=_optional_string(metadata.get("title") or metadata.get("og:title")),
|
|
142
|
+
description=_optional_string(
|
|
143
|
+
metadata.get("description") or metadata.get("og:description")
|
|
144
|
+
),
|
|
145
|
+
content_type=_optional_string(metadata.get("contentType")),
|
|
146
|
+
source_http_status=source_status,
|
|
147
|
+
provider_http_status=status,
|
|
148
|
+
credits_used=credits_used,
|
|
149
|
+
elapsed_ms=elapsed_ms,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _optional_string(value: object) -> str | None:
|
|
154
|
+
return value if isinstance(value, str) else None
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/Ishikawa-Hidekazu/public-source-extractor/blob/main/schemas/output-v0.1.schema.json",
|
|
4
|
+
"title": "Public Source Extractor Output v0.1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schema_version", "ok"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schema_version": {"const": "0.1"},
|
|
10
|
+
"ok": {"type": "boolean"},
|
|
11
|
+
"source": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"required": ["requested_url", "resolved_url", "fetched_at"],
|
|
15
|
+
"properties": {
|
|
16
|
+
"requested_url": {"type": "string", "format": "uri"},
|
|
17
|
+
"resolved_url": {"type": ["string", "null"], "format": "uri"},
|
|
18
|
+
"fetched_at": {"type": "string", "format": "date-time"}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"mode": {"enum": ["markdown", "json"]},
|
|
22
|
+
"content": {"oneOf": [{"type": "string"}, {"type": "object"}]},
|
|
23
|
+
"metadata": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"additionalProperties": false,
|
|
26
|
+
"required": ["title", "description", "content_type", "source_http_status"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"title": {"type": ["string", "null"]},
|
|
29
|
+
"description": {"type": ["string", "null"]},
|
|
30
|
+
"content_type": {"type": ["string", "null"]},
|
|
31
|
+
"source_http_status": {"type": ["integer", "null"], "minimum": 100, "maximum": 599}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"provider": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"additionalProperties": false,
|
|
37
|
+
"required": ["name", "access", "http_status", "credits_used", "elapsed_ms"],
|
|
38
|
+
"properties": {
|
|
39
|
+
"name": {"type": "string", "const": "firecrawl-keyless", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"},
|
|
40
|
+
"access": {"const": "experimental"},
|
|
41
|
+
"http_status": {"type": "integer", "minimum": 100, "maximum": 599},
|
|
42
|
+
"credits_used": {"type": ["integer", "null"], "minimum": 0},
|
|
43
|
+
"elapsed_ms": {"type": "integer", "minimum": 0}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"warnings": {
|
|
47
|
+
"type": "array",
|
|
48
|
+
"items": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"additionalProperties": false,
|
|
51
|
+
"required": ["code", "message"],
|
|
52
|
+
"properties": {
|
|
53
|
+
"code": {"type": "string", "pattern": "^[a-z0-9_]+$"},
|
|
54
|
+
"message": {"type": "string"}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"error": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"additionalProperties": false,
|
|
61
|
+
"required": ["code", "message", "retryable"],
|
|
62
|
+
"properties": {
|
|
63
|
+
"code": {"type": "string", "pattern": "^[a-z0-9_]+$"},
|
|
64
|
+
"message": {"type": "string"},
|
|
65
|
+
"retryable": {"type": "boolean"}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"allOf": [
|
|
70
|
+
{
|
|
71
|
+
"if": {"properties": {"ok": {"const": true}}, "required": ["ok"]},
|
|
72
|
+
"then": {
|
|
73
|
+
"required": ["source", "mode", "content", "metadata", "provider", "warnings"],
|
|
74
|
+
"not": {"required": ["error"]}
|
|
75
|
+
},
|
|
76
|
+
"else": {
|
|
77
|
+
"required": ["error"],
|
|
78
|
+
"not": {"anyOf": [{"required": ["content"]}, {"required": ["provider"]}]}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"if": {"properties": {"ok": {"const": true}, "mode": {"const": "markdown"}}, "required": ["ok", "mode"]},
|
|
83
|
+
"then": {"properties": {"content": {"type": "string"}}}
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"if": {"properties": {"ok": {"const": true}, "mode": {"const": "json"}}, "required": ["ok", "mode"]},
|
|
87
|
+
"then": {"properties": {"content": {"type": "object"}}}
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import re
|
|
5
|
+
import urllib.parse
|
|
6
|
+
|
|
7
|
+
from .errors import InputRejected
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
BLOCKED_HOSTS = {
|
|
11
|
+
"accounts.google.com",
|
|
12
|
+
"calendar.google.com",
|
|
13
|
+
"docs.google.com",
|
|
14
|
+
"drive.google.com",
|
|
15
|
+
"mail.google.com",
|
|
16
|
+
"search.google.com",
|
|
17
|
+
"twitter.com",
|
|
18
|
+
"x.com",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
LOCAL_SUFFIXES = (".local", ".localhost", ".internal", ".home", ".lan")
|
|
22
|
+
|
|
23
|
+
BLOCKED_PATH_RE = re.compile(
|
|
24
|
+
r"(^|/)(wp-admin|wp-login\.php|admin|login|logout|oauth|callback)(/|$)",
|
|
25
|
+
re.IGNORECASE,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
DENIED_QUERY_NAMES = {
|
|
29
|
+
"access_key",
|
|
30
|
+
"access_token",
|
|
31
|
+
"api_key",
|
|
32
|
+
"apikey",
|
|
33
|
+
"auth",
|
|
34
|
+
"authorization",
|
|
35
|
+
"code",
|
|
36
|
+
"cookie",
|
|
37
|
+
"credential",
|
|
38
|
+
"credentials",
|
|
39
|
+
"key",
|
|
40
|
+
"passwd",
|
|
41
|
+
"password",
|
|
42
|
+
"refresh_token",
|
|
43
|
+
"secret",
|
|
44
|
+
"session",
|
|
45
|
+
"sessionid",
|
|
46
|
+
"sig",
|
|
47
|
+
"token",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
DENIED_QUERY_SEGMENTS = {
|
|
51
|
+
"auth",
|
|
52
|
+
"authorization",
|
|
53
|
+
"cookie",
|
|
54
|
+
"credential",
|
|
55
|
+
"credentials",
|
|
56
|
+
"key",
|
|
57
|
+
"passwd",
|
|
58
|
+
"password",
|
|
59
|
+
"secret",
|
|
60
|
+
"session",
|
|
61
|
+
"signature",
|
|
62
|
+
"token",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _decode_repeated(value: str, rounds: int = 3) -> str:
|
|
67
|
+
decoded = value
|
|
68
|
+
for _ in range(rounds):
|
|
69
|
+
next_value = urllib.parse.unquote(decoded)
|
|
70
|
+
if next_value == decoded:
|
|
71
|
+
break
|
|
72
|
+
decoded = next_value
|
|
73
|
+
return decoded
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _query_name_is_sensitive(name: str) -> bool:
|
|
77
|
+
decoded = _decode_repeated(name).lower()
|
|
78
|
+
canonical = re.sub(r"[^a-z0-9]+", "_", decoded).strip("_")
|
|
79
|
+
if canonical in DENIED_QUERY_NAMES:
|
|
80
|
+
return True
|
|
81
|
+
segments = {segment for segment in canonical.split("_") if segment}
|
|
82
|
+
return bool(segments & DENIED_QUERY_SEGMENTS)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _validate_host(host: str) -> None:
|
|
86
|
+
if not host or any(ord(char) > 127 for char in host):
|
|
87
|
+
raise InputRejected()
|
|
88
|
+
if "%" in host or "\\" in host:
|
|
89
|
+
raise InputRejected()
|
|
90
|
+
|
|
91
|
+
normalized = host.lower().rstrip(".")
|
|
92
|
+
if normalized in {"localhost", "local"} or normalized.endswith(LOCAL_SUFFIXES):
|
|
93
|
+
raise InputRejected()
|
|
94
|
+
if normalized in BLOCKED_HOSTS or any(
|
|
95
|
+
normalized.endswith(f".{blocked}") for blocked in BLOCKED_HOSTS
|
|
96
|
+
):
|
|
97
|
+
raise InputRejected()
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
ip = ipaddress.ip_address(normalized)
|
|
101
|
+
except ValueError:
|
|
102
|
+
ip = None
|
|
103
|
+
|
|
104
|
+
if ip is not None:
|
|
105
|
+
if not ip.is_global:
|
|
106
|
+
raise InputRejected()
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
# Reject legacy integer, octal, and hex IPv4 spellings instead of relying on resolver behavior.
|
|
110
|
+
if re.fullmatch(r"[0-9.]+", normalized) or normalized.startswith(("0x", "0X")):
|
|
111
|
+
raise InputRejected()
|
|
112
|
+
if not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?", normalized):
|
|
113
|
+
raise InputRejected()
|
|
114
|
+
if ".." in normalized or "." not in normalized:
|
|
115
|
+
raise InputRejected()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_public_url(raw_url: str) -> urllib.parse.ParseResult:
|
|
119
|
+
if not raw_url or any(char in raw_url for char in ("\r", "\n", "\x00")):
|
|
120
|
+
raise InputRejected()
|
|
121
|
+
|
|
122
|
+
parsed = urllib.parse.urlparse(raw_url)
|
|
123
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
|
|
124
|
+
raise InputRejected()
|
|
125
|
+
if parsed.username or parsed.password or parsed.fragment:
|
|
126
|
+
raise InputRejected()
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
port = parsed.port
|
|
130
|
+
except ValueError as exc:
|
|
131
|
+
raise InputRejected() from exc
|
|
132
|
+
expected_port = 443 if parsed.scheme == "https" else 80
|
|
133
|
+
if port is not None and port != expected_port:
|
|
134
|
+
raise InputRejected()
|
|
135
|
+
|
|
136
|
+
raw_host = parsed.netloc.rsplit("@", 1)[-1]
|
|
137
|
+
if raw_host.startswith("["):
|
|
138
|
+
raw_host = raw_host[1 : raw_host.find("]")]
|
|
139
|
+
else:
|
|
140
|
+
raw_host = raw_host.rsplit(":", 1)[0] if ":" in raw_host else raw_host
|
|
141
|
+
if "%" in raw_host or any(ord(char) > 127 for char in raw_host):
|
|
142
|
+
raise InputRejected()
|
|
143
|
+
_validate_host(parsed.hostname)
|
|
144
|
+
|
|
145
|
+
decoded_path = _decode_repeated(parsed.path or "/").replace("\\", "/")
|
|
146
|
+
if BLOCKED_PATH_RE.search(decoded_path):
|
|
147
|
+
raise InputRejected()
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
query_pairs = urllib.parse.parse_qsl(
|
|
151
|
+
parsed.query,
|
|
152
|
+
keep_blank_values=True,
|
|
153
|
+
strict_parsing=False,
|
|
154
|
+
max_num_fields=100,
|
|
155
|
+
)
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
raise InputRejected() from exc
|
|
158
|
+
for name, _value in query_pairs:
|
|
159
|
+
if _query_name_is_sensitive(name):
|
|
160
|
+
raise InputRejected()
|
|
161
|
+
|
|
162
|
+
return parsed
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def validate_provider_resolved_url(raw_url: object) -> str | None:
|
|
166
|
+
if raw_url is None:
|
|
167
|
+
return None
|
|
168
|
+
if not isinstance(raw_url, str):
|
|
169
|
+
raise InputRejected()
|
|
170
|
+
validate_public_url(raw_url)
|
|
171
|
+
return raw_url
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: public-source-extractor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Validate a public URL and convert it into reviewable AI-ready Markdown or JSON.
|
|
5
|
+
Author: Ishikawa Hidekazu
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://taupe.site/entry/public-source-extractor-ai-research-cli/
|
|
8
|
+
Project-URL: Repository, https://github.com/Ishikawa-Hidekazu/public-source-extractor
|
|
9
|
+
Project-URL: Issues, https://github.com/Ishikawa-Hidekazu/public-source-extractor/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/Ishikawa-Hidekazu/public-source-extractor/blob/main/CHANGELOG.md
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: build>=1.2.2; extra == "dev"
|
|
21
|
+
Requires-Dist: jsonschema>=4.23; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff<0.17,>=0.12; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# Public Source Extractor
|
|
26
|
+
|
|
27
|
+
Public Source Extractor validates one public HTTP or HTTPS URL and converts it
|
|
28
|
+
into reviewable Markdown or a versioned JSON envelope for AI research
|
|
29
|
+
workflows.
|
|
30
|
+
|
|
31
|
+
> **Provider boundary:** extraction sends the selected public URL to Firecrawl
|
|
32
|
+
> Cloud through the experimental `firecrawl-keyless` provider. Availability,
|
|
33
|
+
> anonymous access, credit limits, and long-term continuity are not guaranteed.
|
|
34
|
+
|
|
35
|
+
The CLI does not read API keys, credentials, cookies, browser profiles,
|
|
36
|
+
localStorage, or private source files. Extracted content is untrusted and may
|
|
37
|
+
contain prompt injection or misleading instructions.
|
|
38
|
+
|
|
39
|
+
## Install and run
|
|
40
|
+
|
|
41
|
+
Run the package without a permanent install:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uvx public-source-extractor@0.1.0 --version
|
|
45
|
+
uvx public-source-extractor@0.1.0 https://example.com/
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Install it as an isolated command:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pipx install public-source-extractor==0.1.0
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or install it in an existing Python 3.11+ environment:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python3 -m pip install public-source-extractor==0.1.0
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Output modes
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
public-source-extractor https://example.com/
|
|
64
|
+
public-source-extractor https://example.com/ --mode json --pretty
|
|
65
|
+
public-source-extractor https://example.com/ --output report.md
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The output path must have an existing non-symlink parent and must not already
|
|
69
|
+
exist. The CLI rejects local, private, authenticated, administrative, signed,
|
|
70
|
+
and credential-bearing URL patterns.
|
|
71
|
+
|
|
72
|
+
## Review boundary
|
|
73
|
+
|
|
74
|
+
The extractor creates an intake artifact. It does not establish source
|
|
75
|
+
reliability, execute extracted instructions, crawl a site, or access private
|
|
76
|
+
pages. Verify important claims against the original page and other primary
|
|
77
|
+
sources.
|
|
78
|
+
|
|
79
|
+
- [Repository](https://github.com/Ishikawa-Hidekazu/public-source-extractor)
|
|
80
|
+
- [Documentation](https://github.com/Ishikawa-Hidekazu/public-source-extractor#readme)
|
|
81
|
+
- [Security policy](https://github.com/Ishikawa-Hidekazu/public-source-extractor/security/policy)
|
|
82
|
+
- [Changelog](https://github.com/Ishikawa-Hidekazu/public-source-extractor/blob/main/CHANGELOG.md)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
public_source_extractor/__init__.py,sha256=2uwsTyCs5WJy4JiIcyKQVEC8Go1qbg3vheO3g_ItUfY,62
|
|
2
|
+
public_source_extractor/__main__.py,sha256=4BTAfHHF2u3j_oUg_Wluqu0HyxnXfAHBQUcnPQ-e1xs,50
|
|
3
|
+
public_source_extractor/cli.py,sha256=83rLUPa5UwP4WmO-lBeaPPNxGriYAOyJZQLsI6dP88E,2265
|
|
4
|
+
public_source_extractor/errors.py,sha256=CM1hD09Z6LpuW-9tAltt_LOUN3y-ulteNorzNhcZTHc,1594
|
|
5
|
+
public_source_extractor/output.py,sha256=WfouveBMseAzvUtZRgYA9HFhgjQDGqNQXbaNZ6jSidI,4201
|
|
6
|
+
public_source_extractor/provider.py,sha256=09LCnoHTO_sVoF3w2Ros1nTli6D7N3ooTMondliZfkg,5375
|
|
7
|
+
public_source_extractor/url_policy.py,sha256=O3y3H7WANahtNw_mA70w7YXgRiSfrQ5mBtfBtETWi8s,4585
|
|
8
|
+
public_source_extractor/schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
public_source_extractor/schemas/output-v0.1.schema.json,sha256=g_jI026fIlkEv9xbwlOtL05-9rCTz1rAeu1GmIYRJ7I,3288
|
|
10
|
+
public_source_extractor-0.1.0.dist-info/licenses/LICENSE,sha256=GIuyYJ3MmxBeSj1qQof3BPeEmm_vrdY74hNw621LX_g,1075
|
|
11
|
+
public_source_extractor-0.1.0.dist-info/METADATA,sha256=DKyXHRbP8GWZnle66lgRg3oxLsdbohS9lq6Xh4SsBz4,3127
|
|
12
|
+
public_source_extractor-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
public_source_extractor-0.1.0.dist-info/entry_points.txt,sha256=gFp0UdMNcmUWP3Kn4oAshglkyKskYJbXec6rWxp8RMc,77
|
|
14
|
+
public_source_extractor-0.1.0.dist-info/top_level.txt,sha256=xeq1Zecr5hCHGHjWY3sGm4m07RYbwtNvHuQstpaR6TM,24
|
|
15
|
+
public_source_extractor-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ishikawa Hidekazu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
public_source_extractor
|