nexscope-cli 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.
- nexscope_cli/__init__.py +4 -0
- nexscope_cli/api-contract.json +37 -0
- nexscope_cli/capabilities/__init__.py +3 -0
- nexscope_cli/capabilities/catalog.py +73 -0
- nexscope_cli/capabilities/mock.py +53 -0
- nexscope_cli/capabilities/plan.py +142 -0
- nexscope_cli/catalog.json +69 -0
- nexscope_cli/compat/__init__.py +4 -0
- nexscope_cli/compat/legacy.py +143 -0
- nexscope_cli/core/__init__.py +3 -0
- nexscope_cli/core/auth.py +81 -0
- nexscope_cli/core/datasets.py +305 -0
- nexscope_cli/core/errors.py +11 -0
- nexscope_cli/core/evidence.py +134 -0
- nexscope_cli/core/execution.py +171 -0
- nexscope_cli/core/remote.py +22 -0
- nexscope_cli/core/transport.py +61 -0
- nexscope_cli/dependencies.lock +282 -0
- nexscope_cli/dependency-lock.json +371 -0
- nexscope_cli/doctor.py +136 -0
- nexscope_cli/main.py +313 -0
- nexscope_cli/reports/__init__.py +3 -0
- nexscope_cli/reports/compare.py +135 -0
- nexscope_cli/reports/formats.py +99 -0
- nexscope_cli/resources/__init__.py +3 -0
- nexscope_cli/resources/fields.json +33 -0
- nexscope_cli/resources/mock-products.json +9 -0
- nexscope_cli/resources/products-sample.csv +5 -0
- nexscope_cli/resources/products-sample.json +6 -0
- nexscope_cli/resources/products-template.csv +1 -0
- nexscope_cli/resources/products.schema.json +182 -0
- nexscope_cli/resources/report.html +15 -0
- nexscope_cli/runner.py +43 -0
- nexscope_cli/skills/amazon-product-detail/SKILL.md +170 -0
- nexscope_cli/skills/amazon-product-detail/references/api.md +239 -0
- nexscope_cli/skills/amazon-product-detail/requirements.txt +1 -0
- nexscope_cli/skills/amazon-product-detail/scripts/amazon_product_detail.py +26 -0
- nexscope_cli/skills/amazon-search/SKILL.md +197 -0
- nexscope_cli/skills/amazon-search/references/api.md +189 -0
- nexscope_cli/skills/amazon-search/requirements.txt +1 -0
- nexscope_cli/skills/amazon-search/scripts/amazon_search.py +26 -0
- nexscope_cli/workflows/__init__.py +3 -0
- nexscope_cli/workflows/compare.py +213 -0
- nexscope_cli-0.1.0.dist-info/METADATA +257 -0
- nexscope_cli-0.1.0.dist-info/RECORD +74 -0
- nexscope_cli-0.1.0.dist-info/WHEEL +5 -0
- nexscope_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nexscope_cli-0.1.0.dist-info/licenses/ASSET-NOTICES.md +9 -0
- nexscope_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- nexscope_cli-0.1.0.dist-info/licenses/NOTICE +20 -0
- nexscope_cli-0.1.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +27 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/backports-tarfile/LICENSE +17 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/certifi/LICENSE +20 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cffi/LICENSE +23 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/charset-normalizer/LICENSE +21 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE +3 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE.APACHE +202 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/cryptography/LICENSE.BSD +27 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/idna/LICENSE.md +31 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/importlib-metadata/LICENSE +73 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-classes/LICENSE +17 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-context/LICENSE +18 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jaraco-functools/LICENSE +18 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/jeepney/LICENSE +21 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/keyring/LICENSE +18 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/more-itertools/LICENSE +19 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/pycparser/LICENSE +27 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/pywin32-ctypes/LICENSE.txt +28 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/requests/LICENSE +175 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/requests/NOTICE +2 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/secretstorage/LICENSE +25 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/urllib3/LICENSE.txt +21 -0
- nexscope_cli-0.1.0.dist-info/licenses/third-party-notices/zipp/LICENSE +18 -0
- nexscope_cli-0.1.0.dist-info/top_level.txt +1 -0
nexscope_cli/main.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# Copyright 2026 Nexscope
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Independent, OS-keyring-backed Nexscope CLI. Never prints credentials."""
|
|
4
|
+
import argparse
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
from urllib.error import HTTPError, URLError
|
|
12
|
+
from urllib.request import Request, build_opener, HTTPRedirectHandler
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .core.datasets import DatasetError
|
|
16
|
+
from .core.auth import DEFAULT_ORIGIN, MODES, origin, resolve_auth
|
|
17
|
+
from .core.errors import AuthRequired, Failure
|
|
18
|
+
|
|
19
|
+
ROOT = Path(__file__).parent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NoRedirect(HTTPRedirectHandler):
|
|
23
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
24
|
+
raise Failure('Redirect refused; check the API origin.')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Client:
|
|
28
|
+
def __init__(self, base):
|
|
29
|
+
self.base = origin(base)
|
|
30
|
+
import keyring
|
|
31
|
+
backend = keyring.get_keyring()
|
|
32
|
+
# Fail closed: do not fall back to plaintext files or inherited API keys.
|
|
33
|
+
module = type(backend).__module__
|
|
34
|
+
if module not in ('keyring.backends.macOS', 'keyring.backends.Windows', 'keyring.backends.SecretService', 'keyring.backends.kwallet'):
|
|
35
|
+
raise Failure('An OS credential store is required. Configure a supported keyring backend.')
|
|
36
|
+
self.store = backend
|
|
37
|
+
self.service = 'nexscope-cli:' + self.base
|
|
38
|
+
|
|
39
|
+
def load(self, name):
|
|
40
|
+
raw = self.store.get_password(self.service, name)
|
|
41
|
+
return json.loads(raw) if raw else None
|
|
42
|
+
|
|
43
|
+
def save(self, name, value):
|
|
44
|
+
self.store.set_password(self.service, name, json.dumps(value))
|
|
45
|
+
|
|
46
|
+
def clear(self, name):
|
|
47
|
+
if self.load(name) is not None:
|
|
48
|
+
self.store.delete_password(self.service, name)
|
|
49
|
+
|
|
50
|
+
def api(self, path, body=None, authenticated=False, context=None):
|
|
51
|
+
headers = {'Accept': 'application/json', 'Content-Type': 'application/json'}
|
|
52
|
+
if authenticated:
|
|
53
|
+
context = context or resolve_auth('browser-account', base=self.base, credential_loader=lambda: self.load('credential'))
|
|
54
|
+
if context.mode != 'browser-account' or context.base != self.base:
|
|
55
|
+
raise Failure('Browser API requires its own origin-bound browser-account context.')
|
|
56
|
+
headers['Authorization'] = context.authorization()
|
|
57
|
+
req = Request(self.base + '/api/cli/' + path, data=None if body is None else json.dumps(body).encode(), headers=headers)
|
|
58
|
+
try:
|
|
59
|
+
with build_opener(NoRedirect()).open(req, timeout=30) as response:
|
|
60
|
+
result = json.load(response)
|
|
61
|
+
except HTTPError as e:
|
|
62
|
+
e.close()
|
|
63
|
+
if e.code == 401 and authenticated:
|
|
64
|
+
raise AuthRequired('AUTH_REQUIRED: reconnect with nexscope auth login.') from None
|
|
65
|
+
raise Failure(f'HTTP {e.code}; request not retried.') from None
|
|
66
|
+
except (URLError, TimeoutError):
|
|
67
|
+
raise Failure('Network unavailable; request not retried.') from None
|
|
68
|
+
if not isinstance(result, dict):
|
|
69
|
+
raise Failure('Invalid API response.')
|
|
70
|
+
if result.get('code') in (12001, 12003):
|
|
71
|
+
raise AuthRequired('AUTH_REQUIRED: reconnect with nexscope auth login.')
|
|
72
|
+
if result.get('code') != 0:
|
|
73
|
+
raise Failure(f"API error {result.get('code')}; request not retried.")
|
|
74
|
+
return result.get('data')
|
|
75
|
+
|
|
76
|
+
def auth(self, action, attribution=None):
|
|
77
|
+
if action == 'login':
|
|
78
|
+
if self.load('credential'):
|
|
79
|
+
try:
|
|
80
|
+
return self.api('account', authenticated=True)
|
|
81
|
+
except AuthRequired:
|
|
82
|
+
self.clear('credential')
|
|
83
|
+
pending = self.load('pending')
|
|
84
|
+
import time
|
|
85
|
+
if not pending or int(pending['expiresAt']) <= time.time() * 1000:
|
|
86
|
+
pending = self.api('auth/start', {} if attribution is None else {'attribution': attribution})
|
|
87
|
+
self.save('pending', pending)
|
|
88
|
+
return {k: v for k, v in pending.items() if k != 'pollSecret'}
|
|
89
|
+
if action == 'poll':
|
|
90
|
+
pending = self.load('pending')
|
|
91
|
+
if not pending:
|
|
92
|
+
return self.api('account', authenticated=True)
|
|
93
|
+
body = {k: pending[k] for k in ('requestId', 'pollSecret')}
|
|
94
|
+
value = self.api('auth/poll', body)
|
|
95
|
+
if value['status'] == 'READY':
|
|
96
|
+
self.save('credential', value)
|
|
97
|
+
self.api('auth/poll', {**body, 'acknowledge': True})
|
|
98
|
+
self.clear('pending')
|
|
99
|
+
return self.api('account', authenticated=True)
|
|
100
|
+
if value['status'] in ('DENIED', 'AUTH_EXPIRED', 'CONNECTED'):
|
|
101
|
+
self.clear('pending')
|
|
102
|
+
return {k: v for k, v in value.items() if k != 'apiKey'}
|
|
103
|
+
if action == 'logout':
|
|
104
|
+
if self.load('credential'):
|
|
105
|
+
self.api('disconnect', {}, authenticated=True)
|
|
106
|
+
self.clear('credential')
|
|
107
|
+
self.clear('pending')
|
|
108
|
+
return {'status': 'DISCONNECTED'}
|
|
109
|
+
return self.api('account', authenticated=True)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def catalog():
|
|
113
|
+
return json.loads((ROOT / 'catalog.json').read_text())['skills']
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def guide(name):
|
|
117
|
+
skills = catalog()
|
|
118
|
+
if name == 'core':
|
|
119
|
+
return {'version': __version__, 'skills': {k: v['description'] for k, v in skills.items() if v.get('available', True)},
|
|
120
|
+
'unavailable': {k: v['unavailable'] for k, v in skills.items() if not v.get('available', True)},
|
|
121
|
+
'execution': 'Read get-skills <name>; use run <name> [--script path] -- <script arguments>. Never run packaged scripts directly. Auth: login, user done, poll. Credits: account, link, user done, account.'}
|
|
122
|
+
if name not in skills:
|
|
123
|
+
raise Failure('Unknown skill; use get-skills core.')
|
|
124
|
+
if not skills[name].get('available', True):
|
|
125
|
+
return {'status': 'UNAVAILABLE', 'skill': name, **skills[name]['unavailable']}
|
|
126
|
+
return {'skill': name, 'resourceDirectory': str(ROOT / 'skills' / name), 'scripts': skills[name]['scripts'], 'instructions': (ROOT / 'skills' / name / 'SKILL.md').read_text(encoding='utf-8'),
|
|
127
|
+
'execution': 'Replace python script.py with nexscope run ' + name + ' --script <listed script> -- . Relative input paths must be absolute because runs use isolated output directories.'}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run(args, client):
|
|
131
|
+
entry = catalog().get(args.skill)
|
|
132
|
+
if not entry:
|
|
133
|
+
raise Failure('Unknown skill.')
|
|
134
|
+
if not entry.get('available', True):
|
|
135
|
+
raise Failure('UNAVAILABLE: ' + entry['unavailable']['reason'])
|
|
136
|
+
script = args.script or (entry['scripts'][0] if len(entry['scripts']) == 1 else None)
|
|
137
|
+
if script not in entry['scripts']:
|
|
138
|
+
raise Failure('Choose --script from get-skills output.')
|
|
139
|
+
context = resolve_auth('browser-account', base=client.base, credential_loader=lambda: client.load('credential'))
|
|
140
|
+
account = client.api('account', authenticated=True, context=context)
|
|
141
|
+
folder = Path(args.output).resolve() / str(uuid.uuid4())
|
|
142
|
+
folder.mkdir(parents=True, mode=0o700)
|
|
143
|
+
env = context.script_environment()
|
|
144
|
+
arguments = args.arguments[1:] if args.arguments[:1] == ['--'] else args.arguments
|
|
145
|
+
# No automatic replay: scripts may create reports, change ads, or consume credits.
|
|
146
|
+
result = subprocess.run([sys.executable, str(ROOT / 'runner.py'), str(ROOT / 'skills' / args.skill / script), *arguments], cwd=folder, env=env, capture_output=True, text=True)
|
|
147
|
+
output = context.redact(result.stdout + result.stderr)
|
|
148
|
+
(folder / 'output.txt').write_text(output)
|
|
149
|
+
files = list(folder.rglob('*.json'))
|
|
150
|
+
failures = []
|
|
151
|
+
for path in files:
|
|
152
|
+
try:
|
|
153
|
+
raw = path.read_text()
|
|
154
|
+
cleaned = context.redact(raw)
|
|
155
|
+
if raw != cleaned:
|
|
156
|
+
path.write_text(cleaned)
|
|
157
|
+
data = json.loads(cleaned)
|
|
158
|
+
if isinstance(data, dict) and (data.get('error') or data.get('success') is False or data.get('code', 0) not in (0, '0', None) or data.get('errcode', 0) not in (0, '0', None)):
|
|
159
|
+
failures.append(str(path))
|
|
160
|
+
except (ValueError, OSError):
|
|
161
|
+
pass
|
|
162
|
+
summary = {'status': 'REVIEW_REQUIRED' if result.returncode or failures else 'OUTPUT_READY', 'exitCode': result.returncode,
|
|
163
|
+
'accountId': account['accountId'], 'output': str(folder), 'errorFiles': failures,
|
|
164
|
+
'preview': output[:8000], 'rechargeUrl': account['rechargeUrl']}
|
|
165
|
+
(folder / 'result.json').write_text(json.dumps(summary, indent=2))
|
|
166
|
+
return summary
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def export(args):
|
|
170
|
+
rows = json.loads(Path(args.input).read_text())
|
|
171
|
+
for key in args.path.split('.') if args.path else []:
|
|
172
|
+
rows = rows[int(key)] if isinstance(rows, list) else rows[key]
|
|
173
|
+
if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows):
|
|
174
|
+
raise Failure('CSV input must select a list of objects.')
|
|
175
|
+
fields = list(dict.fromkeys(k for row in rows for k in row))
|
|
176
|
+
def cell(value):
|
|
177
|
+
value = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value if value is not None else '')
|
|
178
|
+
return "'" + value if value.lstrip().startswith(('=', '+', '-', '@')) else value
|
|
179
|
+
with open(args.output, 'x', newline='', encoding='utf-8-sig') as f:
|
|
180
|
+
writer = csv.writer(f)
|
|
181
|
+
writer.writerow([cell(k) for k in fields])
|
|
182
|
+
writer.writerows([cell(row.get(k)) for k in fields] for row in rows)
|
|
183
|
+
return {'status': 'EXPORTED', 'rows': len(rows), 'file': str(Path(args.output).resolve())}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main():
|
|
187
|
+
from .capabilities.mock import SCENARIOS
|
|
188
|
+
parser = argparse.ArgumentParser(prog='nexscope')
|
|
189
|
+
parser.add_argument('--version', action='version', version=__version__)
|
|
190
|
+
parser.add_argument('--api-base', default=None)
|
|
191
|
+
commands = parser.add_subparsers(dest='command', required=True)
|
|
192
|
+
doctor = commands.add_parser('doctor', help='Local diagnostics; no network or stored credential access.')
|
|
193
|
+
doctor.add_argument('--profile', choices=['offline', 'independent', 'plugin'], default='offline')
|
|
194
|
+
doctor.add_argument('--offline', dest='profile', action='store_const', const='offline', help='Alias for --profile offline.')
|
|
195
|
+
doctor.add_argument('--check-output', help='Probe an existing directory with a temporary file, then remove the probe.')
|
|
196
|
+
doctor.add_argument('--json', action='store_true', help='JSON is the default output format.')
|
|
197
|
+
commands.add_parser('get-skills').add_argument('skill', nargs='?', default='core')
|
|
198
|
+
auth_parser = commands.add_parser('auth')
|
|
199
|
+
auth_parser.add_argument('action', choices=['login', 'poll', 'status', 'logout'])
|
|
200
|
+
auth_parser.add_argument('--source-client', choices=['python-cli', 'codex-plugin', 'claude-code'])
|
|
201
|
+
auth_parser.add_argument('--source-channel', choices=['github', 'marketplace', 'local', 'unknown'], default='unknown')
|
|
202
|
+
auth_parser.add_argument('--source-entrypoint', choices=['cli', 'plugin', 'report'], default='cli')
|
|
203
|
+
commands.add_parser('account')
|
|
204
|
+
capabilities = commands.add_parser('capabilities', help='Offline, versioned pilot capability descriptions.')
|
|
205
|
+
describe_capability = capabilities.add_subparsers(dest='capability_action', required=True).add_parser('describe')
|
|
206
|
+
describe_capability.add_argument('capability')
|
|
207
|
+
describe_capability.add_argument('--json', action='store_true', help='JSON is the default output format.')
|
|
208
|
+
data = commands.add_parser('data', help='Plan bounded data requests without network or credentials.')
|
|
209
|
+
data_run = data.add_subparsers(dest='data_action', required=True).add_parser('run')
|
|
210
|
+
data_run.add_argument('capability')
|
|
211
|
+
data_run.add_argument('--input', required=True)
|
|
212
|
+
execution = data_run.add_mutually_exclusive_group(required=True)
|
|
213
|
+
execution.add_argument('--dry-run', action='store_true')
|
|
214
|
+
execution.add_argument('--execute', action='store_true', help='Execute an explicit mock scenario or authorized remote scope.')
|
|
215
|
+
data_run.add_argument('--mock', choices=SCENARIOS)
|
|
216
|
+
data_run.add_argument('--allow-paid', action='store_true', help='Explicitly authorize bounded remote calls; max-calls is not a monetary cap.')
|
|
217
|
+
data_run.add_argument('--out', help='New evidence directory for mock execution.')
|
|
218
|
+
data_run.add_argument('--auth-mode', choices=MODES, default='browser-account')
|
|
219
|
+
data_run.add_argument('--max-calls', type=int, default=3)
|
|
220
|
+
data_run.add_argument('--json', action='store_true', help='JSON is the default output format.')
|
|
221
|
+
workflows = commands.add_parser('workflow', help='Product comparison workflow; remote calls require --execute --allow-paid.')
|
|
222
|
+
workflow = workflows.add_subparsers(dest='workflow_action', required=True).add_parser('compare')
|
|
223
|
+
inputs = workflow.add_mutually_exclusive_group(required=True)
|
|
224
|
+
inputs.add_argument('--asins')
|
|
225
|
+
inputs.add_argument('--keyword')
|
|
226
|
+
inputs.add_argument('--resume', help='Import a completed candidates directory; do not repeat its search.')
|
|
227
|
+
workflow.add_argument('--select-asins')
|
|
228
|
+
workflow.add_argument('--market', help='Defaults to amazon.com, or the saved marketplace with --resume.')
|
|
229
|
+
workflow.add_argument('--auth-mode', choices=MODES, help='Defaults to browser-account, or the saved mode with --resume.')
|
|
230
|
+
workflow.add_argument('--max-calls', type=int, help='Defaults to 4, or the original cumulative budget with --resume.')
|
|
231
|
+
workflow.add_argument('--out', help='New output directory; never overwrite existing evidence.')
|
|
232
|
+
workflow.add_argument('--format', choices=['html', 'csv', 'markdown', 'all'], default='html')
|
|
233
|
+
workflow.add_argument('--mock', choices=SCENARIOS)
|
|
234
|
+
workflow.add_argument('--allow-paid', action='store_true', help='Explicitly authorize bounded remote calls; never retries.')
|
|
235
|
+
workflow.add_argument('--json', action='store_true')
|
|
236
|
+
workflow_execution = workflow.add_mutually_exclusive_group(required=True)
|
|
237
|
+
workflow_execution.add_argument('--dry-run', action='store_true')
|
|
238
|
+
workflow_execution.add_argument('--execute', action='store_true')
|
|
239
|
+
r = commands.add_parser('run')
|
|
240
|
+
r.add_argument('skill'); r.add_argument('--script'); r.add_argument('--output', default='nexscope')
|
|
241
|
+
e = commands.add_parser('export')
|
|
242
|
+
e.add_argument('input'); e.add_argument('--path', default=''); e.add_argument('--output', required=True)
|
|
243
|
+
report = commands.add_parser('report', help='Offline reports from your own data; no account needed.')
|
|
244
|
+
reports = report.add_subparsers(dest='report_action', required=True)
|
|
245
|
+
describe = reports.add_parser('describe', help='Describe input fields, formats and local limits.')
|
|
246
|
+
describe.add_argument('--schema', action='store_true', help='Print the JSON input schema.')
|
|
247
|
+
template = reports.add_parser('template', help='Export a blank standard CSV template.')
|
|
248
|
+
template.add_argument('--format', choices=['csv'], default='csv')
|
|
249
|
+
template.add_argument('--out', required=True)
|
|
250
|
+
demo = reports.add_parser('demo', help='Export fictional sample data (not live market data).')
|
|
251
|
+
demo.add_argument('--format', choices=['csv', 'json'], default='csv')
|
|
252
|
+
demo.add_argument('--out', required=True)
|
|
253
|
+
compare = reports.add_parser('compare', help='Compare a user CSV, JSON array or evidence-backed dataset offline.')
|
|
254
|
+
compare.add_argument('--input', required=True)
|
|
255
|
+
compare.add_argument('--format', choices=['html', 'csv', 'markdown'], default='html')
|
|
256
|
+
compare.add_argument('--out', required=True)
|
|
257
|
+
compare.add_argument('--dry-run', action='store_true', help='Validate and describe outputs without writing.')
|
|
258
|
+
demos = commands.add_parser('demo', help='Run a complete fictional comparison without credentials or network.')
|
|
259
|
+
comparison_demo = demos.add_subparsers(dest='demo_action', required=True).add_parser('product-comparison')
|
|
260
|
+
comparison_demo.add_argument('--out', required=True, help='New output directory.')
|
|
261
|
+
# Split script arguments explicitly so option names cannot change CLI behavior.
|
|
262
|
+
argv = sys.argv[1:]
|
|
263
|
+
tail = argv.index('--') if '--' in argv else len(argv)
|
|
264
|
+
args = parser.parse_args(argv[:tail])
|
|
265
|
+
args.arguments = argv[tail + 1:]
|
|
266
|
+
try:
|
|
267
|
+
if args.command == 'doctor':
|
|
268
|
+
from .doctor import diagnose
|
|
269
|
+
value = diagnose(args.profile, args.check_output, args.api_base)
|
|
270
|
+
elif args.command == 'get-skills': value = guide(args.skill)
|
|
271
|
+
elif args.command == 'export': value = export(args)
|
|
272
|
+
elif args.command == 'report':
|
|
273
|
+
from .reports.compare import command
|
|
274
|
+
value = command(args)
|
|
275
|
+
elif args.command == 'capabilities':
|
|
276
|
+
from .capabilities.catalog import describe
|
|
277
|
+
value = describe(args.capability)
|
|
278
|
+
elif args.command == 'data':
|
|
279
|
+
from .capabilities.plan import command
|
|
280
|
+
value = command(args)
|
|
281
|
+
elif args.command == 'workflow':
|
|
282
|
+
from .workflows.compare import command
|
|
283
|
+
value = command(args)
|
|
284
|
+
elif args.command == 'demo':
|
|
285
|
+
from .capabilities.mock import executor
|
|
286
|
+
from .workflows.compare import compare
|
|
287
|
+
value = compare(executor('browser-account', 'complete', 3), args.out,
|
|
288
|
+
asins='B000000001,B000000002,B000000003', mock=True, format='all')
|
|
289
|
+
else:
|
|
290
|
+
client = Client(DEFAULT_ORIGIN if args.api_base is None else args.api_base)
|
|
291
|
+
source = None
|
|
292
|
+
if args.command == 'auth' and args.source_client:
|
|
293
|
+
if args.action != 'login':
|
|
294
|
+
raise Failure('Source options are supported only for auth login.')
|
|
295
|
+
source = {'version': 1, 'client': args.source_client, 'channel': args.source_channel,
|
|
296
|
+
'campaign': 'cli-data-release', 'entrypoint': args.source_entrypoint}
|
|
297
|
+
value = run(args, client) if args.command == 'run' else client.auth(args.action if args.command == 'auth' else 'status', source)
|
|
298
|
+
print(json.dumps(value, ensure_ascii=False, indent=2))
|
|
299
|
+
return 1 if value.get('status') in ('REVIEW_REQUIRED', 'UNAVAILABLE', 'PARTIAL', 'FAILED', 'INSUFFICIENT_DATA') else 0
|
|
300
|
+
except DatasetError as e:
|
|
301
|
+
print(json.dumps({'status': 'INVALID_INPUT', 'message': str(e), 'issues': e.issues}))
|
|
302
|
+
return 1
|
|
303
|
+
except (Failure, OSError, ValueError, KeyError) as e:
|
|
304
|
+
print(json.dumps({'status': 'ERROR', 'message': str(e)}))
|
|
305
|
+
return 1
|
|
306
|
+
except Exception as e:
|
|
307
|
+
# Backend/keyring exceptions may embed secret-bearing request details.
|
|
308
|
+
print(json.dumps({'status': 'ERROR', 'message': 'Operation failed (' + type(e).__name__ + '). No automatic retry; check the credential store or saved output.'}))
|
|
309
|
+
return 1
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == '__main__':
|
|
313
|
+
sys.exit(main())
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Copyright 2026 Nexscope
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Self-contained HTML reports with input evidence alongside the report."""
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import hashlib
|
|
7
|
+
from html import escape
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
from string import Template
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
from nexscope_cli.core.datasets import FIELDS, read_dataset
|
|
16
|
+
from nexscope_cli.core.evidence import Artifacts, decode, read_bundle
|
|
17
|
+
from .formats import FORMATS, render_csv, render_markdown
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resource(name):
|
|
21
|
+
return files('nexscope_cli.resources').joinpath(name)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def display(value):
|
|
25
|
+
if value is None or value == '':
|
|
26
|
+
return 'Not provided'
|
|
27
|
+
if isinstance(value, (dict, list)):
|
|
28
|
+
value = json.dumps(value, ensure_ascii=False)
|
|
29
|
+
return escape(str(value))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render(dataset, evidence_name):
|
|
33
|
+
groups = defaultdict(list)
|
|
34
|
+
for product in dataset['products']:
|
|
35
|
+
groups[(product['marketplace'], product['currency'], product['rating_scale'])].append(product)
|
|
36
|
+
sections = []
|
|
37
|
+
for (market, currency, scale), products in groups.items():
|
|
38
|
+
title = f'Market: {display(market)} | Currency: {display(currency)} | Rating scale: {display(scale)}'
|
|
39
|
+
headers = ''.join(f'<th>{field}</th>' for field in (*FIELDS, 'source_row', 'extra'))
|
|
40
|
+
rows = []
|
|
41
|
+
for product in products:
|
|
42
|
+
cells = []
|
|
43
|
+
for field in (*FIELDS, 'source_row', 'extra'):
|
|
44
|
+
value = product[field]
|
|
45
|
+
content = display(value)
|
|
46
|
+
if field == 'source_url' and value:
|
|
47
|
+
content = f'<a rel="noreferrer noopener" href="{escape(value, quote=True)}">{content}</a>'
|
|
48
|
+
if field == 'extra' and dataset['sourceType'] in ('mock', 'remote'):
|
|
49
|
+
filename = value.get('evidenceFile', '')
|
|
50
|
+
if re.fullmatch(r'request-[0-9]+\.json', filename):
|
|
51
|
+
href = escape(quote(evidence_name) + '/' + filename, quote=True)
|
|
52
|
+
content = f'<a href="{href}">Request evidence</a><br>' + content
|
|
53
|
+
cells.append(f'<td>{content}</td>')
|
|
54
|
+
rows.append('<tr>' + ''.join(cells) + '</tr>')
|
|
55
|
+
sections.append(f'<section><h2>{title}</h2><p>{len(products)} products</p><div class="scroll"><table><thead><tr>{headers}</tr></thead><tbody>{"".join(rows)}</tbody></table></div></section>')
|
|
56
|
+
missing = sum(any(product[field] is None for field in FIELDS) for product in dataset['products'])
|
|
57
|
+
notice = 'Source: user_import. Data was supplied by the user and has not been refreshed or independently verified. Bundled samples are fictional.'
|
|
58
|
+
if dataset['sourceType'] in ('mock', 'remote'):
|
|
59
|
+
notice = ('MOCK / FICTIONAL DATA: no live service was queried. This report tests the workflow; it is not market research.'
|
|
60
|
+
if dataset['sourceType'] == 'mock' else 'REMOTE DATA: retrieved at the recorded times; missing fields are not inferred. Review the request evidence and partial failures.')
|
|
61
|
+
sections.insert(0, '<section><h2>Collection status: ' + display(dataset['collectionStatus']) + '</h2><p>Failed or skipped products: ' + display(dataset['errors']) + '</p><p>Request accounting: ' + display(dataset['budget']) + '</p></section>')
|
|
62
|
+
return Template(resource('report.html').read_text(encoding='utf-8')).substitute(
|
|
63
|
+
count=len(dataset['products']), missing=missing, groups=len(groups),
|
|
64
|
+
imported=escape(dataset['importedAt']), digest=dataset['inputSha256'],
|
|
65
|
+
evidence=escape(quote(evidence_name), quote=True), sections='\n'.join(sections),
|
|
66
|
+
source_notice=escape(notice),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def render_format(dataset, evidence_name, format='html'):
|
|
71
|
+
if format == 'html':
|
|
72
|
+
return render(dataset, evidence_name)
|
|
73
|
+
if format == 'csv':
|
|
74
|
+
return render_csv(dataset, evidence_name)
|
|
75
|
+
if format == 'markdown':
|
|
76
|
+
return render_markdown(dataset, evidence_name)
|
|
77
|
+
raise ValueError('Choose html, csv or markdown.')
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def compare(input_path, output_path, dry_run=False, format='html'):
|
|
81
|
+
dataset, original = read_dataset(input_path)
|
|
82
|
+
versioned = Path(input_path).suffix.lower() == '.json' and isinstance(decode(original), dict)
|
|
83
|
+
bundle = read_bundle(Path(input_path).parent) if versioned else None
|
|
84
|
+
output = Path(output_path).resolve()
|
|
85
|
+
evidence = output.with_name(output.name + '.evidence')
|
|
86
|
+
if format not in FORMATS or output.suffix.lower() != FORMATS[format]:
|
|
87
|
+
raise ValueError('Report format must match its .html, .csv or .md output suffix.')
|
|
88
|
+
if not output.parent.is_dir():
|
|
89
|
+
raise NotADirectoryError('Report parent must be an existing directory.')
|
|
90
|
+
if output.exists() or evidence.exists():
|
|
91
|
+
raise FileExistsError('Choose a new report path; report or evidence already exists.')
|
|
92
|
+
if dry_run:
|
|
93
|
+
return {'status': 'PLAN_READY', 'rows': len(dataset['products']), 'networkCalls': 0,
|
|
94
|
+
'output': str(output), 'evidence': str(evidence), 'sourceType': dataset['sourceType'], 'format': format}
|
|
95
|
+
# Exclusive creation prevents replacement of either the input or existing outputs.
|
|
96
|
+
artifacts = Artifacts(evidence)
|
|
97
|
+
created = []
|
|
98
|
+
try:
|
|
99
|
+
if bundle is not None:
|
|
100
|
+
for name, raw in bundle.items():
|
|
101
|
+
if name != 'report-input.json':
|
|
102
|
+
artifacts.write_bytes(name, raw)
|
|
103
|
+
else:
|
|
104
|
+
artifacts.write_bytes(dataset['inputName'], original)
|
|
105
|
+
artifacts.write('dataset.json', dataset)
|
|
106
|
+
artifacts.write('report-input.json', {'inputName': Path(input_path).name,
|
|
107
|
+
'inputFile': 'dataset.json' if versioned else dataset['inputName'],
|
|
108
|
+
'inputSha256': hashlib.sha256(original).hexdigest(),
|
|
109
|
+
'exportedAt': datetime.now(timezone.utc).isoformat()})
|
|
110
|
+
artifacts.finish()
|
|
111
|
+
with output.open('x', encoding='utf-8', newline='') as stream:
|
|
112
|
+
created.append(output)
|
|
113
|
+
stream.write(render_format(dataset, evidence.name, format))
|
|
114
|
+
except Exception:
|
|
115
|
+
for path in reversed(created):
|
|
116
|
+
path.unlink(missing_ok=True)
|
|
117
|
+
for name in artifacts.files:
|
|
118
|
+
(evidence / name).unlink(missing_ok=True)
|
|
119
|
+
(evidence / 'manifest.json').unlink(missing_ok=True)
|
|
120
|
+
evidence.rmdir()
|
|
121
|
+
raise
|
|
122
|
+
return {'status': 'REPORT_READY', 'rows': len(dataset['products']), 'networkCalls': 0,
|
|
123
|
+
'file': str(output), 'evidence': str(evidence), 'sourceType': dataset['sourceType'], 'format': format}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def command(args):
|
|
127
|
+
if args.report_action == 'compare':
|
|
128
|
+
return compare(args.input, args.out, args.dry_run, args.format)
|
|
129
|
+
if args.report_action == 'describe':
|
|
130
|
+
name = 'products.schema.json' if args.schema else 'fields.json'
|
|
131
|
+
return json.loads(resource(name).read_text(encoding='utf-8'))
|
|
132
|
+
name = 'products-template.csv' if args.report_action == 'template' else 'products-sample.' + args.format
|
|
133
|
+
with open(args.out, 'xb') as stream:
|
|
134
|
+
stream.write(resource(name).read_bytes())
|
|
135
|
+
return {'status': 'EXPORTED', 'file': str(Path(args.out).resolve()), 'networkCalls': 0}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Copyright 2026 Nexscope
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Evidence-linked CSV and Markdown views of the same comparison dataset."""
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
import csv
|
|
6
|
+
from html import escape
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from ..core.datasets import FIELDS
|
|
13
|
+
|
|
14
|
+
FORMATS = {'html': '.html', 'csv': '.csv', 'markdown': '.md'}
|
|
15
|
+
CONNECT = 'https://github.com/nexscope-ai/nexscope-cli#connect'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def text(value):
|
|
19
|
+
if value is None:
|
|
20
|
+
return ''
|
|
21
|
+
if isinstance(value, (dict, list)):
|
|
22
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
23
|
+
return str(value)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def csv_cell(value):
|
|
27
|
+
value = text(value)
|
|
28
|
+
if value.startswith(('\t', '\r', '\n')) or value.lstrip().startswith(('=', '+', '-', '@')):
|
|
29
|
+
return "'" + value
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def md_cell(value):
|
|
34
|
+
value = 'Not provided' if value is None or value == '' else text(value)
|
|
35
|
+
value = escape(value, quote=True)
|
|
36
|
+
value = re.sub(r'([\\`*_{}\[\]()#+.!|~-])', r'\\\1', value)
|
|
37
|
+
return value.replace('\r\n', '\n').replace('\r', '\n').replace('\n', '<br>')
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def grouped(dataset):
|
|
41
|
+
groups = defaultdict(list)
|
|
42
|
+
for row in dataset['products']:
|
|
43
|
+
groups[(row['marketplace'], row['currency'], row['rating_scale'])].append(row)
|
|
44
|
+
return groups
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def notice(dataset):
|
|
48
|
+
if dataset['sourceType'] in ('mock', 'remote'):
|
|
49
|
+
return ('MOCK / FICTIONAL DATA: no live service queried; not market research.' if dataset['sourceType'] == 'mock'
|
|
50
|
+
else 'REMOTE DATA: source and retrieval times are in request evidence; missing values are not inferred.')
|
|
51
|
+
return 'USER IMPORT: supplied data, not refreshed or independently verified; bundled samples are fictional.'
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render_csv(dataset, evidence_name):
|
|
55
|
+
stream = io.StringIO(newline='')
|
|
56
|
+
columns = ('comparison_group', *FIELDS, 'source_row', 'extra', 'source_type', 'collection_status',
|
|
57
|
+
'fetched_at', 'cache_hit', 'missing_fields', 'evidence_index', 'input_sha256', 'imported_at',
|
|
58
|
+
'errors', 'request_accounting', 'data_notice', 'comparison_policy', 'connect_instructions')
|
|
59
|
+
writer = csv.writer(stream, quoting=csv.QUOTE_ALL, lineterminator='\r\n')
|
|
60
|
+
writer.writerow(columns)
|
|
61
|
+
for group_id, rows in enumerate(grouped(dataset).values(), 1):
|
|
62
|
+
for row in rows:
|
|
63
|
+
extra = row['extra']
|
|
64
|
+
values = (group_id, *(row[field] for field in FIELDS), row['source_row'], extra,
|
|
65
|
+
dataset['sourceType'], dataset.get('collectionStatus', 'IMPORTED'),
|
|
66
|
+
extra.get('fetchedAt') if dataset['sourceType'] in ('mock', 'remote') else None,
|
|
67
|
+
extra.get('cacheHit') if dataset['sourceType'] in ('mock', 'remote') else None,
|
|
68
|
+
[field for field in FIELDS if row[field] is None],
|
|
69
|
+
quote(evidence_name, safe='') + '/dataset.json', dataset['inputSha256'], dataset['importedAt'],
|
|
70
|
+
dataset.get('errors', []), dataset.get('budget'), notice(dataset),
|
|
71
|
+
'Compare only within the same market/currency/rating-scale group; no ranking or conversion.', CONNECT)
|
|
72
|
+
writer.writerow([csv_cell(value) for value in values])
|
|
73
|
+
# BOM helps spreadsheet apps identify UTF-8; exact source values remain in evidence.
|
|
74
|
+
return '\ufeff' + stream.getvalue()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def render_markdown(dataset, evidence_name):
|
|
78
|
+
groups = grouped(dataset)
|
|
79
|
+
lines = ['# Product comparison', '', '**' + notice(dataset) + '**', '',
|
|
80
|
+
f"{len(dataset['products'])} products across {len(groups)} comparable groups.",
|
|
81
|
+
'Compare only within the same market, currency and rating scale. No cross-group ranking or conversion.', '',
|
|
82
|
+
'Collection status: ' + md_cell(dataset.get('collectionStatus', 'IMPORTED')), '',
|
|
83
|
+
'Imported at: ' + md_cell(dataset['importedAt']) + ' (not the observation time).', '',
|
|
84
|
+
'Input SHA-256: ' + md_cell(dataset['inputSha256']), '',
|
|
85
|
+
'[Evidence dataset and exact source values](' + quote(evidence_name, safe='') + '/dataset.json)', '',
|
|
86
|
+
'Failed or skipped products: ' + md_cell(dataset.get('errors', [])), '',
|
|
87
|
+
'Request accounting: ' + md_cell(dataset.get('budget')), '']
|
|
88
|
+
fields = (*FIELDS, 'source_row', 'extra')
|
|
89
|
+
for index, ((market, currency, scale), rows) in enumerate(groups.items(), 1):
|
|
90
|
+
lines += [f'## Group {index}', '',
|
|
91
|
+
'Market: ' + md_cell(market) + ' / Currency: ' + md_cell(currency) + ' / Rating scale: ' + md_cell(scale), '',
|
|
92
|
+
'| ' + ' | '.join(fields) + ' |', '| ' + ' | '.join('---' for _ in fields) + ' |']
|
|
93
|
+
for row in rows:
|
|
94
|
+
lines.append('| ' + ' | '.join(md_cell(row[field]) for field in fields) + ' |')
|
|
95
|
+
lines.append('')
|
|
96
|
+
lines += ['Keep this report with its evidence directory. Missing fields remain unknown.', '',
|
|
97
|
+
'[Need updated product data? Connect Nexscope](' + CONNECT + ').',
|
|
98
|
+
'Optional instructions only: this report does not upload inputs or call data services.', '']
|
|
99
|
+
return '\n'.join(lines)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"sourceType": "user_import",
|
|
4
|
+
"formats": [
|
|
5
|
+
"UTF-8 CSV with unique headers",
|
|
6
|
+
"UTF-8 JSON array of objects",
|
|
7
|
+
"Versioned dataset.json with its complete hash-checked evidence directory"
|
|
8
|
+
],
|
|
9
|
+
"limits": {
|
|
10
|
+
"bytes": 10485760,
|
|
11
|
+
"products": 10000,
|
|
12
|
+
"requiresAccount": false,
|
|
13
|
+
"versionedDatasetBytes": 33554432,
|
|
14
|
+
"evidenceBundleBytes": 67108864
|
|
15
|
+
},
|
|
16
|
+
"fields": {
|
|
17
|
+
"product_id": "Required non-empty string; unique within marketplace; not restricted to ASINs.",
|
|
18
|
+
"title": "Required non-empty string. Never generated or inferred.",
|
|
19
|
+
"marketplace": "Optional market or internal catalog name. Missing is unknown.",
|
|
20
|
+
"price": "Optional finite non-negative number. Requires currency.",
|
|
21
|
+
"currency": "Optional uppercase three-letter currency code. No currency conversion.",
|
|
22
|
+
"rating": "Optional finite non-negative number, no greater than rating_scale.",
|
|
23
|
+
"rating_scale": "Positive finite number, required with rating. No implicit five-point scale.",
|
|
24
|
+
"review_count": "Optional non-negative integer. Missing does not mean zero.",
|
|
25
|
+
"source_url": "Optional HTTP(S) URL without credentials or whitespace.",
|
|
26
|
+
"observed_at": "Optional ISO 8601 timestamp with timezone. Import time is recorded separately."
|
|
27
|
+
},
|
|
28
|
+
"additionalFields": "Preserved as extra metadata; no automatic header mapping.",
|
|
29
|
+
"errors": "All invalid records are reported; no report is produced until input is corrected. CSV row numbers count logical records including the header; JSON rows are one-based array positions.",
|
|
30
|
+
"provenance": "Keep the report and its .evidence directory together. It contains the exact input, input SHA-256, import time, source rows and normalized dataset. Missing source/time stays unknown. Re-export preserves the original sourceType, inputName/inputSha256, retrieval times and errors; report-input.json separately records the exact selected input hash. Hashes prove consistency, not server authorship.",
|
|
31
|
+
"samples": "Bundled inputs are invented examples, not current market data.",
|
|
32
|
+
"offline": "No authentication, network, or LLM call. Reports contain user data: review them before sharing. The optional Nexscope link opens a guide; it never refreshes data automatically."
|
|
33
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sourceType": "mock",
|
|
3
|
+
"notice": "Fictional test products. Not live market data.",
|
|
4
|
+
"products": [
|
|
5
|
+
{"asin": "B000000001", "title": "Fictional Alpha bottle", "extractedPrice": 20.5, "currency": "USD", "rating": 4.2, "ratingScale": 5, "reviews": 80, "link": "https://example.invalid/product/B000000001"},
|
|
6
|
+
{"asin": "B000000002", "title": "Fictional Beta bottle", "extractedPrice": 23, "currency": "USD", "rating": 4.5, "ratingScale": 5, "reviews": 40, "link": "https://example.invalid/product/B000000002"},
|
|
7
|
+
{"asin": "B000000003", "title": "Fictional Gamma bottle", "currency": "USD", "link": "https://example.invalid/product/B000000003"}
|
|
8
|
+
]
|
|
9
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
product_id,title,marketplace,price,currency,rating,rating_scale,review_count,source_url,observed_at
|
|
2
|
+
SAMPLE-001,Sample insulated bottle,own-catalog,19.50,USD,4.2,5,120,https://example.com/products/1,2026-01-01T00:00:00Z
|
|
3
|
+
SAMPLE-002,Sample travel bottle,own-catalog,24.00,USD,4.4,5,95,https://example.com/products/2,2026-01-01T00:00:00Z
|
|
4
|
+
SAMPLE-003,Sample compact bottle,own-catalog,15.00,EUR,8,10,50,https://example.com/products/3,2026-01-01T00:00:00Z
|
|
5
|
+
SAMPLE-004,Sample bottle with missing data,own-catalog,,,,,,,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"product_id":"SAMPLE-001","title":"Sample insulated bottle","marketplace":"own-catalog","price":19.50,"currency":"USD","rating":4.2,"rating_scale":5,"review_count":120,"source_url":"https://example.com/products/1","observed_at":"2026-01-01T00:00:00Z"},
|
|
3
|
+
{"product_id":"SAMPLE-002","title":"Sample travel bottle","marketplace":"own-catalog","price":24,"currency":"USD","rating":4.4,"rating_scale":5,"review_count":95,"source_url":"https://example.com/products/2","observed_at":"2026-01-01T00:00:00Z"},
|
|
4
|
+
{"product_id":"SAMPLE-003","title":"Sample compact bottle","marketplace":"own-catalog","price":15,"currency":"EUR","rating":8,"rating_scale":10,"review_count":50,"source_url":"https://example.com/products/3","observed_at":"2026-01-01T00:00:00Z"},
|
|
5
|
+
{"product_id":"SAMPLE-004","title":"Sample bottle with missing data","marketplace":"own-catalog"}
|
|
6
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
product_id,title,marketplace,price,currency,rating,rating_scale,review_count,source_url,observed_at
|