flashdata-google-news 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.
- flashdata_google_news-0.1.0.dist-info/METADATA +97 -0
- flashdata_google_news-0.1.0.dist-info/RECORD +12 -0
- flashdata_google_news-0.1.0.dist-info/WHEEL +4 -0
- flashdata_google_news-0.1.0.dist-info/entry_points.txt +2 -0
- flashdata_google_news-0.1.0.dist-info/licenses/LICENSE +21 -0
- flashdata_google_news_cli/__init__.py +1 -0
- flashdata_google_news_cli/_capability.py +2 -0
- flashdata_google_news_cli/_client.py +152 -0
- flashdata_google_news_cli/_inputs.py +213 -0
- flashdata_google_news_cli/_storage.py +108 -0
- flashdata_google_news_cli/cli.py +201 -0
- flashdata_google_news_cli/storage.schema.json +87 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: flashdata-google-news
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Save a news topic snapshot for a daily briefing with the FlashData JSON CLI
|
|
5
|
+
Project-URL: Homepage, https://flashdata.dev/docs/sources/google_news
|
|
6
|
+
Project-URL: Documentation, https://flashdata.dev/docs/sources/google_news
|
|
7
|
+
Project-URL: Pricing, https://flashdata.dev/pricing
|
|
8
|
+
Project-URL: Support, https://flashdata.dev
|
|
9
|
+
Project-URL: Source, https://github.com/rocosen/flashdata-mcp/tree/main/packages/python-cli
|
|
10
|
+
Author-email: FlashData <support@flashdata.dev>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: cli,flashdata,google-news,json,uvx
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx<1,>=0.28.1
|
|
19
|
+
Requires-Dist: jsonschema<5,>=4.23
|
|
20
|
+
Requires-Dist: pydantic<3,>=2.7.4
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Google News CLI — FlashData
|
|
24
|
+
|
|
25
|
+
Save a news topic snapshot for a daily briefing. This command calls the `google_news` API and returns JSON for your
|
|
26
|
+
shell script, data pipeline or agent tool runner. It does not invoke a language model.
|
|
27
|
+
|
|
28
|
+
Returns news search results, not article full text.
|
|
29
|
+
|
|
30
|
+
## Install and run
|
|
31
|
+
|
|
32
|
+
Requires Python 3.10 or newer. [uv installation](https://docs.astral.sh/uv/getting-started/installation/)
|
|
33
|
+
provides `uvx`, which runs this package in an isolated environment.
|
|
34
|
+
|
|
35
|
+
1. [Create a FlashData account](https://flashdata.dev/signup), obtain an API Key and fund Credits.
|
|
36
|
+
2. Set `FLASHDATA_API_KEY` using your shell or secret manager. The CLI does not accept a key flag
|
|
37
|
+
or persist credentials. [Authentication and source documentation](https://flashdata.dev/docs/sources/google_news).
|
|
38
|
+
3. Run this example after reviewing the input and cost:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
uvx flashdata-google-news@0.1.0 --query 'reusable rockets' --options-json '{"num": 5}' --output result.json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`--output` creates a new file with owner-only permissions where supported and refuses existing
|
|
45
|
+
files before making a paid request. Without it, the full JSON response goes to stdout. Errors
|
|
46
|
+
go to stderr. Exit 0 means the response was exported, 2 means local input/configuration failure,
|
|
47
|
+
and 1 means request or output failure. For downloads, exit 0 can describe an unfinished job.
|
|
48
|
+
|
|
49
|
+
For repeated use, install `uv tool install flashdata-google-news==0.1.0`; the command name is `flashdata-google-news`.
|
|
50
|
+
Alternatively use `python -m pip install flashdata-google-news==0.1.0` in your virtual environment.
|
|
51
|
+
|
|
52
|
+
## Inputs and output
|
|
53
|
+
|
|
54
|
+
Use `--query` for the primary input. Additional options use
|
|
55
|
+
`--options-json` as a JSON object, with strict field/type/range validation. Source is fixed by
|
|
56
|
+
this package. Google count and locale options remain omitted unless supplied.
|
|
57
|
+
|
|
58
|
+
| Option | Meaning |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `num` | Requested result count. Default: `null`. |
|
|
61
|
+
| `page` | Search result page. Default: `null`. |
|
|
62
|
+
| `autocorrect` | Enable spelling autocorrection. Default: `null`. |
|
|
63
|
+
| `gl` | Two-letter country code, for example us. Default: `null`. |
|
|
64
|
+
| `hl` | Interface language code, for example en. Default: `null`. |
|
|
65
|
+
| `location` | Optional geographic search location. Default: `null`. |
|
|
66
|
+
| `tbs` | Optional Google time filter, for example qdr:w. Default: `null`. |
|
|
67
|
+
|
|
68
|
+
Run `uvx flashdata-google-news@0.1.0 --schema` to inspect all fields, nested filters, allowed values and
|
|
69
|
+
defaults without a Key or an API call. `--help` and `--version` are also local-only.
|
|
70
|
+
|
|
71
|
+
The JSON output preserves the API result envelope and optional fields: results[].news: titles, links and available dates/snippets; usage.credits.
|
|
72
|
+
Missing fields remain missing; empty results remain empty. No article text, translated captions,
|
|
73
|
+
model conclusions, rights grants or keyword volumes are invented. Exact source contracts are
|
|
74
|
+
documented [here](https://flashdata.dev/docs/sources/google_news).
|
|
75
|
+
|
|
76
|
+
## Fees and failure handling
|
|
77
|
+
|
|
78
|
+
This MIT-licensed CLI is free to install. FlashData API requests consume paid Credits;
|
|
79
|
+
[current pricing](https://flashdata.dev/pricing) applies. Extended YouTube Search has a separate
|
|
80
|
+
rate. Media delivery is billed by delivered volume, and your cloud-storage costs are separate.
|
|
81
|
+
There is no automatic pagination, retry, polling, scheduled execution or model call.
|
|
82
|
+
|
|
83
|
+
Each HTTP request has a 120-second network timeout (15-second connect timeout) and a 20-MiB response
|
|
84
|
+
limit. A timeout or failure may occur after a paid request has been accepted. Check Jobs in
|
|
85
|
+
the [FlashData console](https://flashdata.dev) before resubmitting. HTTP 401 means check the Key;
|
|
86
|
+
402 means insufficient Credits; 429 means rate/quota limit. A partial output file is retained
|
|
87
|
+
on failure. Do not retry a paid query just because output handling failed.
|
|
88
|
+
|
|
89
|
+
Input values and your API Key are sent to FlashData to perform the request. A completed
|
|
90
|
+
download-result read may fetch a signed JSON object from the result origin returned by
|
|
91
|
+
FlashData; the API Key is never sent to that object URL. Only process data appropriate for
|
|
92
|
+
your output destination. [Privacy policy](https://flashdata.dev/legal/privacy).
|
|
93
|
+
|
|
94
|
+
Source and MIT license are included in the source distribution.
|
|
95
|
+
|
|
96
|
+
[Source documentation](https://flashdata.dev/docs/sources/google_news) ·
|
|
97
|
+
[Pricing](https://flashdata.dev/pricing) · [Sign up](https://flashdata.dev/signup)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
flashdata_google_news_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
flashdata_google_news_cli/_capability.py,sha256=pitfzHUqC81-9ctbzL_hbXtEnyOGn-5vcOoY1BatV20,57
|
|
3
|
+
flashdata_google_news_cli/_client.py,sha256=bkIpwrFeokVc-zRSUg1Jnv8r2DnDLAsvPeOwjuF0rMQ,6484
|
|
4
|
+
flashdata_google_news_cli/_inputs.py,sha256=tV6mQp_9qnZa3-242YXdJ3GEfZh9l-lnvFMrdHEFGNY,9015
|
|
5
|
+
flashdata_google_news_cli/_storage.py,sha256=iOJOGcOETKPE4HZF4RsBG3DiROEG1EFuexK1v_LClyQ,4538
|
|
6
|
+
flashdata_google_news_cli/cli.py,sha256=1mMVfwCu-ohQyuy8MqDw1GeMA-qLqz3gWGhXFlk0a5E,7683
|
|
7
|
+
flashdata_google_news_cli/storage.schema.json,sha256=ppAXH8fRKewYqiI57_r6rfeLSQ4pKqIZbeWB1jzbiPY,2370
|
|
8
|
+
flashdata_google_news-0.1.0.dist-info/METADATA,sha256=-xLdwJstoT899OwuVV6dITc2aJX56gkVTUyaSZPHmgA,5262
|
|
9
|
+
flashdata_google_news-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
flashdata_google_news-0.1.0.dist-info/entry_points.txt,sha256=ogX-YKZTTP3Cdp5aiJBak8R_kY3CV4LRe_ptF0M3gmo,77
|
|
11
|
+
flashdata_google_news-0.1.0.dist-info/licenses/LICENSE,sha256=_WaID44HsDW1TORBvMsdgzkCyWysJarIM_3IVHobB7k,1079
|
|
12
|
+
flashdata_google_news-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FlashData contributors
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Bounded, single-attempt requests for the standalone command-line tools."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urlsplit
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
QUERY_URL = "https://data.flashdata.dev/v1/queries/realtime"
|
|
9
|
+
ASYNC_URL = "https://data.flashdata.dev/v1/queries"
|
|
10
|
+
JOBS_URL = "https://api.flashdata.dev/v2/jobs"
|
|
11
|
+
AUTH_URL = "https://api.flashdata.dev/v2/api-access"
|
|
12
|
+
MAX_RESPONSE_BYTES = 20 * 1024 * 1024
|
|
13
|
+
UNKNOWN_OUTCOME = (
|
|
14
|
+
"The request may already have been accepted and charged. "
|
|
15
|
+
"Check Jobs in the FlashData console before resubmitting. No automatic retry was made."
|
|
16
|
+
)
|
|
17
|
+
READ_ERROR = "Could not complete this read. Retry the same job read; do not submit a new job."
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FlashDataError(Exception):
|
|
21
|
+
"""An error safe to display without raw responses or credentials."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Client:
|
|
25
|
+
def __init__(self, api_key, *, transport=None):
|
|
26
|
+
self.api_key = api_key
|
|
27
|
+
self.transport = transport
|
|
28
|
+
|
|
29
|
+
def _request(self, method, url, payload=None, *, authenticated=True):
|
|
30
|
+
headers = {"Accept": "application/json", "User-Agent": "flashdata-cli/0.1.0"}
|
|
31
|
+
if authenticated:
|
|
32
|
+
headers.update({"X-API-Key": self.api_key, "X-Request-Id": str(uuid4())})
|
|
33
|
+
try:
|
|
34
|
+
with (
|
|
35
|
+
httpx.Client(
|
|
36
|
+
timeout=httpx.Timeout(120.0, connect=15.0),
|
|
37
|
+
follow_redirects=False,
|
|
38
|
+
trust_env=False,
|
|
39
|
+
transport=self.transport,
|
|
40
|
+
) as client,
|
|
41
|
+
client.stream(method, url, json=payload, headers=headers) as response,
|
|
42
|
+
):
|
|
43
|
+
body = bytearray()
|
|
44
|
+
for chunk in response.iter_bytes():
|
|
45
|
+
if len(body) + len(chunk) > MAX_RESPONSE_BYTES:
|
|
46
|
+
raise FlashDataError(
|
|
47
|
+
"Response exceeds 20 MiB. Retrieve it from Jobs; do not resubmit."
|
|
48
|
+
)
|
|
49
|
+
body.extend(chunk)
|
|
50
|
+
# iter_bytes already decodes content encodings; do not decode twice.
|
|
51
|
+
return httpx.Response(
|
|
52
|
+
response.status_code,
|
|
53
|
+
content=bytes(body),
|
|
54
|
+
headers={"location": response.headers.get("location", "")},
|
|
55
|
+
)
|
|
56
|
+
except httpx.HTTPError:
|
|
57
|
+
raise FlashDataError(UNKNOWN_OUTCOME if method == "POST" else READ_ERROR) from None
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _json(response, *, submission=False):
|
|
61
|
+
message = UNKNOWN_OUTCOME if submission else READ_ERROR
|
|
62
|
+
errors = {
|
|
63
|
+
401: "API key rejected. Check FLASHDATA_API_KEY.",
|
|
64
|
+
402: "Insufficient FlashData Credits. Fund your account before a new request.",
|
|
65
|
+
403: "This account or key cannot access the requested source.",
|
|
66
|
+
429: "FlashData rate or quota limit reached. Wait before a new request.",
|
|
67
|
+
}
|
|
68
|
+
if response.status_code != 200:
|
|
69
|
+
raise FlashDataError(
|
|
70
|
+
f"{errors.get(response.status_code, message)} HTTP {response.status_code}."
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
data = response.json()
|
|
74
|
+
except ValueError:
|
|
75
|
+
raise FlashDataError(message) from None
|
|
76
|
+
if not isinstance(data, dict):
|
|
77
|
+
raise FlashDataError(message)
|
|
78
|
+
return data
|
|
79
|
+
|
|
80
|
+
def query(self, payload):
|
|
81
|
+
data = self._json(self._request("POST", QUERY_URL, payload), submission=True)
|
|
82
|
+
if data.get("status") not in (None, "done", "completed") or not isinstance(
|
|
83
|
+
data.get("results"), list
|
|
84
|
+
):
|
|
85
|
+
raise FlashDataError(UNKNOWN_OUTCOME)
|
|
86
|
+
job = data.get("job")
|
|
87
|
+
if isinstance(job, dict) and job.get("status") == "failed":
|
|
88
|
+
raise FlashDataError("The job failed. Inspect it in Jobs before a new request.")
|
|
89
|
+
return data
|
|
90
|
+
|
|
91
|
+
def submit(self, payload):
|
|
92
|
+
from ._inputs import DownloadJobInput
|
|
93
|
+
|
|
94
|
+
data = self._json(self._request("POST", ASYNC_URL, payload), submission=True)
|
|
95
|
+
try:
|
|
96
|
+
DownloadJobInput(job_id=data.get("id"))
|
|
97
|
+
except ValueError:
|
|
98
|
+
raise FlashDataError(UNKNOWN_OUTCOME) from None
|
|
99
|
+
if data.get("status") not in ("pending", "processing"):
|
|
100
|
+
raise FlashDataError(UNKNOWN_OUTCOME)
|
|
101
|
+
return {k: data[k] for k in ("id", "status", "source", "created_at") if k in data}
|
|
102
|
+
|
|
103
|
+
def status(self, job_id):
|
|
104
|
+
from ._inputs import DownloadJobInput
|
|
105
|
+
|
|
106
|
+
DownloadJobInput(job_id=job_id)
|
|
107
|
+
data = self._json(self._request("GET", f"{JOBS_URL}/{job_id}"))
|
|
108
|
+
job = data.get("job")
|
|
109
|
+
if not isinstance(job, dict) or job.get("source") != "youtube_download":
|
|
110
|
+
raise FlashDataError("This reader only accepts YouTube download jobs.")
|
|
111
|
+
if job.get("id") != job_id:
|
|
112
|
+
raise FlashDataError("Job receipt does not match the requested ID.")
|
|
113
|
+
# Job params can contain storage credentials. Export lifecycle fields only.
|
|
114
|
+
return {
|
|
115
|
+
"job": {
|
|
116
|
+
k: job[k]
|
|
117
|
+
for k in ("id", "source", "status", "created_at", "updated_at", "completed_at")
|
|
118
|
+
if k in job
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
def result(self, job_id):
|
|
123
|
+
status = self.status(job_id)
|
|
124
|
+
if status["job"].get("status") != "completed":
|
|
125
|
+
return status
|
|
126
|
+
identity = self._json(self._request("GET", AUTH_URL))
|
|
127
|
+
link = self._request("GET", f"{JOBS_URL}/{job_id}/results")
|
|
128
|
+
try:
|
|
129
|
+
origin = identity.get("result_origin")
|
|
130
|
+
trusted = urlsplit(origin) if isinstance(origin, str) else None
|
|
131
|
+
target = urlsplit(link.headers.get("location", ""))
|
|
132
|
+
if (
|
|
133
|
+
link.status_code != 302
|
|
134
|
+
or not trusted
|
|
135
|
+
or trusted.scheme != "https"
|
|
136
|
+
or not trusted.hostname
|
|
137
|
+
or trusted.username
|
|
138
|
+
or trusted.password
|
|
139
|
+
or trusted.path not in ("", "/")
|
|
140
|
+
or trusted.query
|
|
141
|
+
or trusted.fragment
|
|
142
|
+
or target.scheme != "https"
|
|
143
|
+
or target.username
|
|
144
|
+
or target.password
|
|
145
|
+
or target.fragment
|
|
146
|
+
or (target.hostname, target.port or 443) != (trusted.hostname, trusted.port or 443)
|
|
147
|
+
):
|
|
148
|
+
raise ValueError()
|
|
149
|
+
except ValueError:
|
|
150
|
+
raise FlashDataError("Result URL does not match trusted FlashData storage.") from None
|
|
151
|
+
output = self._json(self._request("GET", target.geturl(), authenticated=False))
|
|
152
|
+
return {**status, "output": output}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Validated tool arguments. Credentials are never part of these schemas."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Annotated, Literal
|
|
5
|
+
from urllib.parse import parse_qs, urlsplit
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
Query = Annotated[str, Field(min_length=1, max_length=2000)]
|
|
10
|
+
Language = Annotated[str, Field(pattern=r"^[A-Za-z0-9-]{1,35}$")]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Input(BaseModel):
|
|
14
|
+
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GoogleInput(_Input):
|
|
18
|
+
"""Google source parameters; omitted settings use provider behavior."""
|
|
19
|
+
|
|
20
|
+
query: Query = Field(description="Search query, between 1 and 2,000 characters.")
|
|
21
|
+
num: int | None = Field(default=None, ge=1, le=100, description="Requested result count.")
|
|
22
|
+
page: int | None = Field(default=None, ge=1, le=100, description="Search result page.")
|
|
23
|
+
autocorrect: bool | None = Field(default=None, description="Enable spelling autocorrection.")
|
|
24
|
+
gl: Annotated[str, Field(pattern=r"^[A-Za-z]{2}$")] | None = Field(
|
|
25
|
+
default=None, description="Two-letter country code, for example us."
|
|
26
|
+
)
|
|
27
|
+
hl: Language | None = Field(
|
|
28
|
+
default=None, description="Interface language code, for example en."
|
|
29
|
+
)
|
|
30
|
+
location: Annotated[str, Field(min_length=1, max_length=200)] | None = Field(
|
|
31
|
+
default=None, description="Optional geographic search location."
|
|
32
|
+
)
|
|
33
|
+
tbs: Annotated[str, Field(min_length=1, max_length=200)] | None = Field(
|
|
34
|
+
default=None, description="Optional Google time filter, for example qdr:w."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GoogleSearchInput(GoogleInput):
|
|
39
|
+
"""Preserve the original Google Search tool defaults."""
|
|
40
|
+
|
|
41
|
+
num: int = Field(default=10, ge=1, le=100)
|
|
42
|
+
page: int = Field(default=1, ge=1, le=100)
|
|
43
|
+
autocorrect: bool = True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class YouTubeSearchFilters(_Input):
|
|
47
|
+
"""Optional YouTube search filters, including feature flags."""
|
|
48
|
+
|
|
49
|
+
duration: Literal["<4", "4-20", ">20"] | None = None
|
|
50
|
+
sort_by: Literal["rating", "relevance", "view_count", "upload_date"] | None = None
|
|
51
|
+
type: Literal["video", "channel", "playlist", "movie"] | None = None
|
|
52
|
+
upload_date: Literal["today", "last_hour", "this_week", "this_month", "this_year"] | None = None
|
|
53
|
+
video_360: bool | None = Field(default=None, alias="360")
|
|
54
|
+
video_3d: bool | None = Field(default=None, alias="3d")
|
|
55
|
+
video_4k: bool | None = Field(default=None, alias="4k")
|
|
56
|
+
creative_commons: bool | None = None
|
|
57
|
+
hd: bool | None = None
|
|
58
|
+
hdr: bool | None = None
|
|
59
|
+
live: bool | None = None
|
|
60
|
+
location: bool | None = None
|
|
61
|
+
purchased: bool | None = None
|
|
62
|
+
subtitles: bool | None = None
|
|
63
|
+
vr180: bool | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class YouTubeSearchInput(_Input):
|
|
67
|
+
"""YouTube video search parameters."""
|
|
68
|
+
|
|
69
|
+
query: Query = Field(description="YouTube search query, between 1 and 2,000 characters.")
|
|
70
|
+
max_results: int = Field(default=10, ge=1, le=20, description="Requested number of videos.")
|
|
71
|
+
filters: YouTubeSearchFilters | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class YouTubeExtendedSearchInput(YouTubeSearchInput):
|
|
75
|
+
"""Larger searches have a separate source and rate; count is not guaranteed."""
|
|
76
|
+
|
|
77
|
+
max_results: int = Field(
|
|
78
|
+
default=20, ge=1, le=700, description="Requested result limit, up to 700."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _VideoInput(_Input):
|
|
83
|
+
video_id: str = Field(description="11-character YouTube video ID or a YouTube video URL.")
|
|
84
|
+
|
|
85
|
+
@field_validator("video_id")
|
|
86
|
+
@classmethod
|
|
87
|
+
def normalize_video_id(cls, value: str) -> str:
|
|
88
|
+
if re.fullmatch(r"[A-Za-z0-9_-]{11}", value):
|
|
89
|
+
return value
|
|
90
|
+
try:
|
|
91
|
+
url = urlsplit(value)
|
|
92
|
+
parts = url.path.split("/")
|
|
93
|
+
candidate = ""
|
|
94
|
+
if url.scheme in ("http", "https") and not url.username and not url.password:
|
|
95
|
+
if url.hostname == "youtu.be" and (
|
|
96
|
+
len(parts) == 2 or (len(parts) == 3 and not parts[2])
|
|
97
|
+
):
|
|
98
|
+
candidate = parts[1]
|
|
99
|
+
elif url.hostname in (
|
|
100
|
+
"youtube.com",
|
|
101
|
+
"www.youtube.com",
|
|
102
|
+
"m.youtube.com",
|
|
103
|
+
"music.youtube.com",
|
|
104
|
+
):
|
|
105
|
+
if url.path == "/watch":
|
|
106
|
+
candidate = parse_qs(url.query).get("v", [""])[0]
|
|
107
|
+
elif (len(parts) == 3 or (len(parts) == 4 and not parts[3])) and parts[1] in (
|
|
108
|
+
"shorts",
|
|
109
|
+
"embed",
|
|
110
|
+
"live",
|
|
111
|
+
):
|
|
112
|
+
candidate = parts[2]
|
|
113
|
+
if re.fullmatch(r"[A-Za-z0-9_-]{11}", candidate):
|
|
114
|
+
return candidate
|
|
115
|
+
except ValueError:
|
|
116
|
+
pass
|
|
117
|
+
raise ValueError("Enter an 11-character YouTube ID or a youtube.com / youtu.be video URL.")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class YouTubeMetadataInput(_VideoInput):
|
|
121
|
+
"""YouTube metadata parameters."""
|
|
122
|
+
|
|
123
|
+
include_chapters: bool = Field(default=True, description="Include available video chapters.")
|
|
124
|
+
include_formats: bool = Field(default=True, description="Include available format metadata.")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class YouTubeTranscriptInput(_VideoInput):
|
|
128
|
+
"""YouTube transcript parameters; no implicit language or caption fallback."""
|
|
129
|
+
|
|
130
|
+
language: Language = Field(default="en", description="Requested caption language code.")
|
|
131
|
+
auto: bool = Field(
|
|
132
|
+
default=True,
|
|
133
|
+
description="True requests automatic captions; false requests manually authored captions.",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class YouTubeCaptionsInput(YouTubeTranscriptInput):
|
|
138
|
+
"""Select uploader captions by default, without fallback."""
|
|
139
|
+
|
|
140
|
+
auto: bool = Field(default=False, description="False selects manual; true automatic captions.")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class YouTubeTrainabilityInput(_VideoInput):
|
|
144
|
+
"""Inspect license labels; this does not establish training permission."""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class YouTubeChannelInput(_Input):
|
|
148
|
+
handle: Annotated[str, Field(min_length=1, max_length=100)] = Field(
|
|
149
|
+
description="YouTube channel handle, for example @jawed."
|
|
150
|
+
)
|
|
151
|
+
video_limit: int = Field(default=20, ge=1, le=100)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class YouTubeSuggestionsInput(_Input):
|
|
155
|
+
query: Query = Field(description="YouTube search prefix.")
|
|
156
|
+
language: Language = "en"
|
|
157
|
+
region: Annotated[str, Field(pattern=r"^[A-Za-z]{2}$")] = "US"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class YouTubeDownloadInput(_VideoInput):
|
|
161
|
+
"""Media options only. Storage credentials are injected by the application."""
|
|
162
|
+
|
|
163
|
+
download_type: Literal["audio_video", "video", "audio"] = "audio_video"
|
|
164
|
+
video_quality: (
|
|
165
|
+
Annotated[
|
|
166
|
+
str,
|
|
167
|
+
Field(pattern=r"^(best|worst|(?:<=|==|>=)(?:144|360|480|720|1080|1440|2160|4320))$"),
|
|
168
|
+
]
|
|
169
|
+
| None
|
|
170
|
+
) = None
|
|
171
|
+
fps: Annotated[str, Field(pattern=r"^(?:>=|<=|==)(?:[1-9]\d?|1\d\d|2[0-3]\d|240)$")] | None = (
|
|
172
|
+
None
|
|
173
|
+
)
|
|
174
|
+
audio_language: Literal["default", "original"] | None = None
|
|
175
|
+
audio_quality: Literal["best", "320", "256", "192", "128"] | None = None
|
|
176
|
+
yt_dlp_format: Annotated[str, Field(min_length=1, max_length=1024)] | None = None
|
|
177
|
+
upload_metadata: bool = False
|
|
178
|
+
output_format: Literal["mp4", "webm", "mkv", "m4a", "mp3", "opus"] | None = None
|
|
179
|
+
start_at: Annotated[str, Field(pattern=r"^\d{1,2}:[0-5]\d:[0-5]\d$")] | None = None
|
|
180
|
+
end_at: Annotated[str, Field(pattern=r"^\d{1,2}:[0-5]\d:[0-5]\d$")] | None = None
|
|
181
|
+
|
|
182
|
+
@model_validator(mode="after")
|
|
183
|
+
def validate_options(self):
|
|
184
|
+
audio = self.download_type == "audio"
|
|
185
|
+
formats = ("m4a", "mp3", "opus") if audio else ("mp4", "webm", "mkv")
|
|
186
|
+
if self.output_format is not None and self.output_format not in formats:
|
|
187
|
+
raise ValueError("output_format is incompatible with download_type.")
|
|
188
|
+
if self.yt_dlp_format and any(ord(c) < 32 or ord(c) == 127 for c in self.yt_dlp_format):
|
|
189
|
+
raise ValueError("yt_dlp_format must not contain control characters.")
|
|
190
|
+
if not self.yt_dlp_format:
|
|
191
|
+
if audio and (self.video_quality is not None or self.fps is not None):
|
|
192
|
+
raise ValueError("video_quality and fps do not apply to audio downloads.")
|
|
193
|
+
if self.download_type == "video" and self.audio_language is not None:
|
|
194
|
+
raise ValueError("audio_language does not apply to video-only downloads.")
|
|
195
|
+
if not audio and self.audio_quality is not None:
|
|
196
|
+
raise ValueError("audio_quality applies only to audio downloads.")
|
|
197
|
+
times = {}
|
|
198
|
+
for field in ("start_at", "end_at"):
|
|
199
|
+
value = getattr(self, field)
|
|
200
|
+
if value is not None:
|
|
201
|
+
h, m, s = map(int, value.split(":"))
|
|
202
|
+
times[field] = h * 3600 + m * 60 + s
|
|
203
|
+
if times[field] > 43200:
|
|
204
|
+
raise ValueError("Clip timestamps must not exceed 12:00:00.")
|
|
205
|
+
if "end_at" in times and times["end_at"] <= times.get("start_at", 0):
|
|
206
|
+
raise ValueError("end_at must be later than start_at.")
|
|
207
|
+
return self
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class DownloadJobInput(_Input):
|
|
211
|
+
job_id: Annotated[str, Field(pattern=r"^job_[A-Za-z0-9-]{1,100}$")] = Field(
|
|
212
|
+
description="Job ID from a YouTube download submission."
|
|
213
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Customer storage validation. Never include credentials in errors."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import parse_qs
|
|
7
|
+
|
|
8
|
+
from jsonschema import Draft7Validator
|
|
9
|
+
|
|
10
|
+
from ._client import FlashDataError
|
|
11
|
+
|
|
12
|
+
STORAGE_SCHEMA = json.loads(Path(__file__).with_name("storage.schema.json").read_text())
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def storage_config(raw):
|
|
16
|
+
try:
|
|
17
|
+
storage = json.loads(raw)
|
|
18
|
+
except (ValueError, TypeError):
|
|
19
|
+
raise FlashDataError("Download storage must contain a JSON object.") from None
|
|
20
|
+
if not Draft7Validator(STORAGE_SCHEMA).is_valid(storage):
|
|
21
|
+
raise FlashDataError("Invalid download storage credential. Check its fields and types.")
|
|
22
|
+
kind = storage["storage_type"]
|
|
23
|
+
bucket = storage["storage_url"].strip()
|
|
24
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]", bucket) or ".." in bucket:
|
|
25
|
+
raise FlashDataError(
|
|
26
|
+
"storage_url must be a bucket/container name; put folders in object_prefix."
|
|
27
|
+
)
|
|
28
|
+
if kind == "s3" and (
|
|
29
|
+
re.fullmatch(r"\d{1,3}(?:\.\d{1,3}){3}", bucket) or ".-" in bucket or "-." in bucket
|
|
30
|
+
):
|
|
31
|
+
raise FlashDataError("storage_url must be a valid AWS S3 bucket name.")
|
|
32
|
+
storage["storage_url"] = bucket
|
|
33
|
+
provider_fields = (
|
|
34
|
+
["service_account"]
|
|
35
|
+
if kind == "gcs"
|
|
36
|
+
else ["account_name", "sas_token"]
|
|
37
|
+
if kind == "azure"
|
|
38
|
+
else ["access_key", "secret_key", "region"]
|
|
39
|
+
if kind == "cos"
|
|
40
|
+
else ["access_key", "secret_key", "region", "endpoint_url"]
|
|
41
|
+
)
|
|
42
|
+
if set(storage) - {"storage_type", "storage_url", "object_prefix", *provider_fields}:
|
|
43
|
+
raise FlashDataError("The storage credential contains fields for a different provider.")
|
|
44
|
+
if "object_prefix" in storage:
|
|
45
|
+
prefix = storage["object_prefix"].strip().removesuffix("/")
|
|
46
|
+
if (
|
|
47
|
+
not prefix
|
|
48
|
+
or len(prefix) > 512
|
|
49
|
+
or any(
|
|
50
|
+
part in (".", "..") or not re.fullmatch(r"[A-Za-z0-9._-]+", part)
|
|
51
|
+
for part in prefix.split("/")
|
|
52
|
+
)
|
|
53
|
+
):
|
|
54
|
+
raise FlashDataError("object_prefix must use safe folder names, up to 512 characters.")
|
|
55
|
+
storage["object_prefix"] = prefix
|
|
56
|
+
if kind == "gcs":
|
|
57
|
+
account = storage.get("service_account", {})
|
|
58
|
+
if (
|
|
59
|
+
not all(account.get(k) for k in ("project_id", "client_email", "private_key"))
|
|
60
|
+
or not re.fullmatch(
|
|
61
|
+
r"[^@\s]+@[^@\s]+\.gserviceaccount\.com", account.get("client_email", "")
|
|
62
|
+
)
|
|
63
|
+
or "BEGIN PRIVATE KEY" not in account.get("private_key", "")
|
|
64
|
+
):
|
|
65
|
+
raise FlashDataError(
|
|
66
|
+
"GCS requires project_id, service-account client_email and a PEM private_key."
|
|
67
|
+
)
|
|
68
|
+
storage["service_account"] = {
|
|
69
|
+
key: account[key] for key in ("project_id", "client_email", "private_key")
|
|
70
|
+
}
|
|
71
|
+
elif kind == "azure":
|
|
72
|
+
sas = storage.get("sas_token", "").removeprefix("?")
|
|
73
|
+
fields = parse_qs(sas)
|
|
74
|
+
if (
|
|
75
|
+
not storage.get("account_name")
|
|
76
|
+
or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", bucket)
|
|
77
|
+
or not all(fields.get(k) for k in ("sig", "se", "sv"))
|
|
78
|
+
or "w" not in fields.get("sp", [""])[0]
|
|
79
|
+
or fields.get("sr") != ["c"]
|
|
80
|
+
or "\n" in sas
|
|
81
|
+
or "\r" in sas
|
|
82
|
+
or len(sas) > 4096
|
|
83
|
+
):
|
|
84
|
+
raise FlashDataError(
|
|
85
|
+
"Azure requires an account name and container SAS with write permission and expiry."
|
|
86
|
+
)
|
|
87
|
+
storage["sas_token"] = sas
|
|
88
|
+
else:
|
|
89
|
+
if not all(storage.get(k, "").strip() for k in ("access_key", "secret_key")):
|
|
90
|
+
raise FlashDataError(
|
|
91
|
+
"Download storage requires access_key and secret_key in the provider credential."
|
|
92
|
+
)
|
|
93
|
+
if kind == "s3_compatible" and not storage.get("endpoint_url"):
|
|
94
|
+
raise FlashDataError("S3-compatible storage requires endpoint_url.")
|
|
95
|
+
region = storage.get("region", "")
|
|
96
|
+
if (
|
|
97
|
+
(kind in ("cos", "bos", "tos") and "." in bucket)
|
|
98
|
+
or (
|
|
99
|
+
kind == "cos"
|
|
100
|
+
and (
|
|
101
|
+
not re.fullmatch(r"ap-[a-z0-9-]+", region) or not re.search(r"-\d{5,}$", bucket)
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
or (kind == "bos" and region not in ("bj", "su", "hkg"))
|
|
105
|
+
or (kind == "tos" and not re.fullmatch(r"(?:cn|ap|eu|us)-[a-z0-9-]+", region))
|
|
106
|
+
):
|
|
107
|
+
raise FlashDataError("Check the storage provider region and bucket name.")
|
|
108
|
+
return storage
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""CLI entry point shared at build time by fifteen independently named packages."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from ._client import UNKNOWN_OUTCOME, Client, FlashDataError
|
|
14
|
+
from ._inputs import (
|
|
15
|
+
DownloadJobInput,
|
|
16
|
+
GoogleInput,
|
|
17
|
+
YouTubeCaptionsInput,
|
|
18
|
+
YouTubeChannelInput,
|
|
19
|
+
YouTubeDownloadInput,
|
|
20
|
+
YouTubeExtendedSearchInput,
|
|
21
|
+
YouTubeMetadataInput,
|
|
22
|
+
YouTubeSearchInput,
|
|
23
|
+
YouTubeSuggestionsInput,
|
|
24
|
+
YouTubeTrainabilityInput,
|
|
25
|
+
YouTubeTranscriptInput,
|
|
26
|
+
)
|
|
27
|
+
from ._storage import storage_config
|
|
28
|
+
|
|
29
|
+
CONTRACTS = {
|
|
30
|
+
**{
|
|
31
|
+
f"google_{kind}": GoogleInput
|
|
32
|
+
for kind in ("search", "news", "images", "shopping", "videos", "places")
|
|
33
|
+
},
|
|
34
|
+
"youtube_search": YouTubeSearchInput,
|
|
35
|
+
"youtube_search_extended": YouTubeExtendedSearchInput,
|
|
36
|
+
"youtube_metadata": YouTubeMetadataInput,
|
|
37
|
+
"youtube_transcript": YouTubeTranscriptInput,
|
|
38
|
+
"youtube_captions": YouTubeCaptionsInput,
|
|
39
|
+
"youtube_trainability": YouTubeTrainabilityInput,
|
|
40
|
+
"youtube_channel": YouTubeChannelInput,
|
|
41
|
+
"youtube_suggestions": YouTubeSuggestionsInput,
|
|
42
|
+
"youtube_download": YouTubeDownloadInput,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Parser(argparse.ArgumentParser):
|
|
47
|
+
def error(self, message):
|
|
48
|
+
raise FlashDataError("Invalid command arguments. Use --help for supported options.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parser_for(source, command):
|
|
52
|
+
parser = Parser(
|
|
53
|
+
prog=command,
|
|
54
|
+
description=(
|
|
55
|
+
f"Run the {source} FlashData API and export JSON. API calls use paid Credits. "
|
|
56
|
+
"Read FLASHDATA_API_KEY from the environment; never pass credentials as arguments."
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument("--version", action="version", version=f"{command} {__version__}")
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--schema", action="store_true", help="Print the input schema; no API call."
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--output", type=Path, help="New JSON file (0600); existing files are refused."
|
|
65
|
+
)
|
|
66
|
+
primary = (
|
|
67
|
+
"handle"
|
|
68
|
+
if source == "youtube_channel"
|
|
69
|
+
else "query"
|
|
70
|
+
if (
|
|
71
|
+
source.startswith("google_")
|
|
72
|
+
or source in ("youtube_search", "youtube_search_extended", "youtube_suggestions")
|
|
73
|
+
)
|
|
74
|
+
else "video-id"
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument("--" + primary, help="Primary query, channel handle, or YouTube ID/URL.")
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--options-json", help="Source options as a JSON object, without credentials."
|
|
79
|
+
)
|
|
80
|
+
if source == "youtube_download":
|
|
81
|
+
parser.add_argument("--operation", choices=["status", "result", "submit"], default="status")
|
|
82
|
+
parser.add_argument("--job-id", help="Previously saved job ID for status/result.")
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--allow-submit", action="store_true", help="Permit one paid storage write."
|
|
85
|
+
)
|
|
86
|
+
return parser
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def payload_for(source, args, environ):
|
|
90
|
+
operation = getattr(args, "operation", "query")
|
|
91
|
+
if operation in ("status", "result"):
|
|
92
|
+
if args.video_id or args.options_json or args.allow_submit:
|
|
93
|
+
raise FlashDataError("Status/result accepts --job-id, without submission inputs.")
|
|
94
|
+
return operation, DownloadJobInput(job_id=args.job_id).model_dump()
|
|
95
|
+
try:
|
|
96
|
+
options = json.loads(args.options_json) if args.options_json is not None else {}
|
|
97
|
+
except ValueError:
|
|
98
|
+
raise FlashDataError("--options-json must be a JSON object.") from None
|
|
99
|
+
if not isinstance(options, dict) or any(
|
|
100
|
+
field in options for field in ("source", "query", "video_id", "handle", "storage")
|
|
101
|
+
):
|
|
102
|
+
raise FlashDataError(
|
|
103
|
+
"Use dedicated primary inputs; source is fixed and secrets use env vars."
|
|
104
|
+
)
|
|
105
|
+
values = dict(options)
|
|
106
|
+
for primary in ("query", "video_id", "handle"):
|
|
107
|
+
value = getattr(args, primary, None)
|
|
108
|
+
if value is not None:
|
|
109
|
+
values[primary] = value
|
|
110
|
+
data = CONTRACTS[source].model_validate(values).model_dump(exclude_none=True, by_alias=True)
|
|
111
|
+
primary = next(key for key in ("query", "video_id", "handle") if key in data)
|
|
112
|
+
payload = {"source": source, primary: data.pop(primary), "params": data}
|
|
113
|
+
if operation == "submit":
|
|
114
|
+
if args.job_id or not args.allow_submit:
|
|
115
|
+
raise FlashDataError(
|
|
116
|
+
"New delivery requires --allow-submit; saved jobs use status/result."
|
|
117
|
+
)
|
|
118
|
+
storage = storage_config(environ.get("FLASHDATA_DOWNLOAD_STORAGE", ""))
|
|
119
|
+
endpoint = storage.get("endpoint_url")
|
|
120
|
+
if endpoint:
|
|
121
|
+
url = urlsplit(endpoint)
|
|
122
|
+
if (
|
|
123
|
+
url.scheme != "https"
|
|
124
|
+
or not url.hostname
|
|
125
|
+
or url.username
|
|
126
|
+
or url.password
|
|
127
|
+
or url.query
|
|
128
|
+
or url.fragment
|
|
129
|
+
):
|
|
130
|
+
raise FlashDataError("Storage endpoint must be an HTTPS URL without credentials.")
|
|
131
|
+
payload["params"]["storage"] = storage
|
|
132
|
+
return operation, payload
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def run(
|
|
136
|
+
source, command, argv=None, *, environ=None, stdout=None, stderr=None, client_factory=Client
|
|
137
|
+
):
|
|
138
|
+
environ = os.environ if environ is None else environ
|
|
139
|
+
stdout = sys.stdout if stdout is None else stdout
|
|
140
|
+
stderr = sys.stderr if stderr is None else stderr
|
|
141
|
+
output = None
|
|
142
|
+
request_started = False
|
|
143
|
+
output_complete = False
|
|
144
|
+
try:
|
|
145
|
+
args = parser_for(source, command).parse_args(argv)
|
|
146
|
+
if args.schema:
|
|
147
|
+
stdout.write(json.dumps(CONTRACTS[source].model_json_schema(), indent=2) + "\n")
|
|
148
|
+
return 0
|
|
149
|
+
operation, payload = payload_for(source, args, environ)
|
|
150
|
+
key = environ.get("FLASHDATA_API_KEY", "").strip()
|
|
151
|
+
if not key or any(ord(c) < 32 or ord(c) == 127 for c in key):
|
|
152
|
+
raise FlashDataError("Set a valid FLASHDATA_API_KEY environment variable.")
|
|
153
|
+
if args.output:
|
|
154
|
+
# Reserve the new path before spending Credits, without replacing user files.
|
|
155
|
+
fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
156
|
+
output = os.fdopen(fd, "w", encoding="utf-8")
|
|
157
|
+
client = client_factory(key)
|
|
158
|
+
request_started = True
|
|
159
|
+
result = (
|
|
160
|
+
client.query(payload)
|
|
161
|
+
if operation == "query"
|
|
162
|
+
else client.submit(payload)
|
|
163
|
+
if operation == "submit"
|
|
164
|
+
else client.status(payload["job_id"])
|
|
165
|
+
if operation == "status"
|
|
166
|
+
else client.result(payload["job_id"])
|
|
167
|
+
)
|
|
168
|
+
destination = output if output else stdout
|
|
169
|
+
destination.write(json.dumps(result, ensure_ascii=False, allow_nan=False, indent=2) + "\n")
|
|
170
|
+
destination.flush()
|
|
171
|
+
output_complete = True
|
|
172
|
+
return 0
|
|
173
|
+
except ValidationError:
|
|
174
|
+
stderr.write("Invalid source input. See --schema for fields, types, ranges and defaults.\n")
|
|
175
|
+
return 2
|
|
176
|
+
except FlashDataError as error:
|
|
177
|
+
stderr.write(str(error) + "\n")
|
|
178
|
+
return 1 if request_started else 2
|
|
179
|
+
except (OSError, ValueError, TypeError):
|
|
180
|
+
stderr.write(
|
|
181
|
+
(
|
|
182
|
+
"Could not save the response. " + UNKNOWN_OUTCOME
|
|
183
|
+
if request_started
|
|
184
|
+
else "Could not prepare output or inputs. Check the new output path."
|
|
185
|
+
)
|
|
186
|
+
+ "\n"
|
|
187
|
+
)
|
|
188
|
+
return 1 if request_started else 2
|
|
189
|
+
finally:
|
|
190
|
+
if output:
|
|
191
|
+
output.close()
|
|
192
|
+
if not output_complete:
|
|
193
|
+
stderr.write(
|
|
194
|
+
"The newly reserved output file is incomplete; check Jobs before retrying.\n"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main():
|
|
199
|
+
from ._capability import COMMAND, SOURCE
|
|
200
|
+
|
|
201
|
+
raise SystemExit(run(SOURCE, COMMAND))
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "object",
|
|
3
|
+
"additionalProperties": false,
|
|
4
|
+
"required": [
|
|
5
|
+
"storage_type",
|
|
6
|
+
"storage_url"
|
|
7
|
+
],
|
|
8
|
+
"description": "Customer-owned destination, using the same fields as Download API params.storage. Supply credentials from your configured storage; do not invent values or display secrets. GCS uses service_account; Azure uses account_name and a container SAS with write permission; other providers use access_key/secret_key and provider endpoint/region as needed.",
|
|
9
|
+
"properties": {
|
|
10
|
+
"storage_type": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"enum": [
|
|
13
|
+
"azure",
|
|
14
|
+
"bos",
|
|
15
|
+
"cos",
|
|
16
|
+
"gcs",
|
|
17
|
+
"oss",
|
|
18
|
+
"s3",
|
|
19
|
+
"s3_compatible",
|
|
20
|
+
"tos"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"storage_url": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"minLength": 1,
|
|
26
|
+
"maxLength": 1024,
|
|
27
|
+
"description": "Bucket name (Azure: container name). Not a local path or a full object URL."
|
|
28
|
+
},
|
|
29
|
+
"object_prefix": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"minLength": 1,
|
|
32
|
+
"maxLength": 513,
|
|
33
|
+
"description": "Optional object folder prefix. Use a distinct prefix when keeping multiple versions of a video."
|
|
34
|
+
},
|
|
35
|
+
"access_key": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"maxLength": 4096
|
|
38
|
+
},
|
|
39
|
+
"secret_key": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"maxLength": 4096
|
|
42
|
+
},
|
|
43
|
+
"endpoint_url": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"maxLength": 4096,
|
|
46
|
+
"description": "Provider endpoint, required for S3-compatible storage. Must be reachable from the download Worker."
|
|
47
|
+
},
|
|
48
|
+
"region": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"maxLength": 4096
|
|
51
|
+
},
|
|
52
|
+
"account_name": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"pattern": "^[a-z0-9]{3,24}$"
|
|
55
|
+
},
|
|
56
|
+
"sas_token": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"maxLength": 4097,
|
|
59
|
+
"description": "Azure container SAS with write permission; never return this secret to the user in output."
|
|
60
|
+
},
|
|
61
|
+
"service_account": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"required": [
|
|
64
|
+
"project_id",
|
|
65
|
+
"client_email",
|
|
66
|
+
"private_key"
|
|
67
|
+
],
|
|
68
|
+
"properties": {
|
|
69
|
+
"project_id": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"minLength": 1,
|
|
72
|
+
"maxLength": 256
|
|
73
|
+
},
|
|
74
|
+
"client_email": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"minLength": 1
|
|
77
|
+
},
|
|
78
|
+
"private_key": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"minLength": 1,
|
|
81
|
+
"maxLength": 16384
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"additionalProperties": true
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|