shipwatch 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.
- shipwatch/__init__.py +5 -0
- shipwatch/__main__.py +4 -0
- shipwatch/_version.py +3 -0
- shipwatch/cli.py +218 -0
- shipwatch/config.py +277 -0
- shipwatch/data/sources.yaml +172 -0
- shipwatch/dates.py +76 -0
- shipwatch/fetch/__init__.py +15 -0
- shipwatch/fetch/client.py +165 -0
- shipwatch/fetch/github.py +42 -0
- shipwatch/fetch/html.py +177 -0
- shipwatch/fetch/robots.py +36 -0
- shipwatch/fetch/rss.py +86 -0
- shipwatch/fetch/sitemap.py +86 -0
- shipwatch/llm.py +185 -0
- shipwatch/models.py +157 -0
- shipwatch/output/__init__.py +9 -0
- shipwatch/output/render.py +190 -0
- shipwatch/pipeline/__init__.py +11 -0
- shipwatch/pipeline/characterize.py +105 -0
- shipwatch/pipeline/cluster.py +55 -0
- shipwatch/pipeline/dedup.py +60 -0
- shipwatch/pipeline/extract.py +56 -0
- shipwatch/pipeline/run.py +370 -0
- shipwatch/pipeline/score.py +155 -0
- shipwatch/pipeline/synthesize.py +143 -0
- shipwatch/pipeline/verify.py +92 -0
- shipwatch/py.typed +0 -0
- shipwatch/storage.py +327 -0
- shipwatch/textutil.py +149 -0
- shipwatch-0.1.0.dist-info/METADATA +323 -0
- shipwatch-0.1.0.dist-info/RECORD +36 -0
- shipwatch-0.1.0.dist-info/WHEEL +5 -0
- shipwatch-0.1.0.dist-info/entry_points.txt +2 -0
- shipwatch-0.1.0.dist-info/licenses/LICENSE +21 -0
- shipwatch-0.1.0.dist-info/top_level.txt +1 -0
shipwatch/__init__.py
ADDED
shipwatch/__main__.py
ADDED
shipwatch/_version.py
ADDED
shipwatch/cli.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from shipwatch._version import __version__
|
|
11
|
+
from shipwatch.config import (
|
|
12
|
+
AppConfig,
|
|
13
|
+
bundled_sources_path,
|
|
14
|
+
load_config,
|
|
15
|
+
load_yaml,
|
|
16
|
+
parse_sources,
|
|
17
|
+
write_init_files,
|
|
18
|
+
)
|
|
19
|
+
from shipwatch.fetch.client import PoliteClient, check_url
|
|
20
|
+
from shipwatch.models import SourceKind
|
|
21
|
+
from shipwatch.output.render import coverage_line, write_outputs
|
|
22
|
+
from shipwatch.pipeline.run import run_brief, run_fetch, run_pipeline
|
|
23
|
+
from shipwatch.storage import Store
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="shipwatch",
|
|
29
|
+
description="Know what AI companies actually shipped this week.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--version", action="version", version=f"shipwatch {__version__}")
|
|
32
|
+
parser.add_argument("--config", type=Path, help="Path to shipwatch.yaml")
|
|
33
|
+
parser.add_argument("--data-dir", type=Path, help="Override SQLite data directory")
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--no-llm",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="Heuristic characterization, scoring, and extractive brief",
|
|
38
|
+
)
|
|
39
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
40
|
+
|
|
41
|
+
init = sub.add_parser("init", help="Write sample config and source pack")
|
|
42
|
+
init.add_argument("--force", action="store_true")
|
|
43
|
+
init.add_argument("--dir", type=Path, default=Path("."))
|
|
44
|
+
|
|
45
|
+
fetch = sub.add_parser("fetch", help="Fetch sources into SQLite")
|
|
46
|
+
fetch.add_argument("--source", help="Fetch a single source id")
|
|
47
|
+
|
|
48
|
+
run = sub.add_parser("run", help="Fetch + score + brief")
|
|
49
|
+
_brief_flags(run)
|
|
50
|
+
|
|
51
|
+
brief = sub.add_parser("brief", help="Brief from items already in SQLite")
|
|
52
|
+
_brief_flags(brief)
|
|
53
|
+
|
|
54
|
+
sources = sub.add_parser("sources", help="Inspect the source pack")
|
|
55
|
+
src_sub = sources.add_subparsers(dest="sources_command", required=True)
|
|
56
|
+
check = src_sub.add_parser("check", help="Validate sources (add --live to hit the network)")
|
|
57
|
+
check.add_argument("--live", action="store_true")
|
|
58
|
+
|
|
59
|
+
sub.add_parser("stats", help="Show archive counts")
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _brief_flags(parser: argparse.ArgumentParser) -> None:
|
|
64
|
+
parser.add_argument("--since", help="Window such as 7d, 2w, or an ISO timestamp")
|
|
65
|
+
parser.add_argument("--top", type=int, dest="top_n", help="How many stories to keep")
|
|
66
|
+
parser.add_argument("--out", type=Path, help="Output directory")
|
|
67
|
+
parser.add_argument("--html", action="store_true", help="Also write a minimal index.html")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main(argv: list[str] | None = None) -> int:
|
|
71
|
+
parser = build_parser()
|
|
72
|
+
args = parser.parse_args(argv)
|
|
73
|
+
try:
|
|
74
|
+
return _dispatch(args)
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
77
|
+
return 1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _dispatch(args: argparse.Namespace) -> int:
|
|
81
|
+
if args.command == "init":
|
|
82
|
+
written = write_init_files(args.dir, force=args.force)
|
|
83
|
+
for path in written:
|
|
84
|
+
print(f"wrote {path}")
|
|
85
|
+
print("Edit sources.yaml, then run: shipwatch fetch && shipwatch run --no-llm")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
cfg = _config(args)
|
|
89
|
+
if args.command == "sources":
|
|
90
|
+
return _sources_check(cfg, live=args.live)
|
|
91
|
+
|
|
92
|
+
store = Store(cfg.db_path)
|
|
93
|
+
try:
|
|
94
|
+
if args.command == "fetch":
|
|
95
|
+
stats = run_fetch(cfg, store, source_id=args.source)
|
|
96
|
+
_print_fetch(stats)
|
|
97
|
+
return 0
|
|
98
|
+
if args.command == "run":
|
|
99
|
+
_apply_brief_overrides(cfg, args)
|
|
100
|
+
briefing, items, stats = run_pipeline(
|
|
101
|
+
cfg,
|
|
102
|
+
store,
|
|
103
|
+
since=args.since,
|
|
104
|
+
top_n=args.top_n,
|
|
105
|
+
no_llm=args.no_llm,
|
|
106
|
+
html=args.html or None,
|
|
107
|
+
)
|
|
108
|
+
_print_fetch(stats)
|
|
109
|
+
_print_brief(briefing, cfg)
|
|
110
|
+
return 0
|
|
111
|
+
if args.command == "brief":
|
|
112
|
+
_apply_brief_overrides(cfg, args)
|
|
113
|
+
briefing, items, _stats = run_brief(
|
|
114
|
+
cfg,
|
|
115
|
+
store,
|
|
116
|
+
since=args.since,
|
|
117
|
+
top_n=args.top_n,
|
|
118
|
+
no_llm=args.no_llm,
|
|
119
|
+
html=args.html or None,
|
|
120
|
+
)
|
|
121
|
+
write_outputs(
|
|
122
|
+
briefing,
|
|
123
|
+
items,
|
|
124
|
+
cfg.brief.output_dir,
|
|
125
|
+
html=args.html or cfg.brief.html,
|
|
126
|
+
archive=store.list_briefings(limit=20),
|
|
127
|
+
)
|
|
128
|
+
_print_brief(briefing, cfg)
|
|
129
|
+
return 0
|
|
130
|
+
if args.command == "stats":
|
|
131
|
+
print(json.dumps(store.stats(), indent=2))
|
|
132
|
+
return 0
|
|
133
|
+
finally:
|
|
134
|
+
store.close()
|
|
135
|
+
return 1
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _config(args: argparse.Namespace) -> AppConfig:
|
|
139
|
+
path = args.config
|
|
140
|
+
if path is None:
|
|
141
|
+
candidate = Path("shipwatch.yaml")
|
|
142
|
+
path = candidate if candidate.exists() else None
|
|
143
|
+
cfg = load_config(path)
|
|
144
|
+
if getattr(args, "data_dir", None):
|
|
145
|
+
cfg.data_dir = args.data_dir
|
|
146
|
+
if getattr(args, "no_llm", False):
|
|
147
|
+
pass
|
|
148
|
+
return cfg
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _apply_brief_overrides(cfg: AppConfig, args: argparse.Namespace) -> None:
|
|
152
|
+
if getattr(args, "out", None):
|
|
153
|
+
cfg.brief.output_dir = args.out
|
|
154
|
+
if getattr(args, "html", False):
|
|
155
|
+
cfg.brief.html = True
|
|
156
|
+
if getattr(args, "since", None):
|
|
157
|
+
cfg.brief.since = args.since
|
|
158
|
+
if getattr(args, "top_n", None):
|
|
159
|
+
cfg.brief.top_n = args.top_n
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _print_fetch(stats) -> None:
|
|
163
|
+
print(
|
|
164
|
+
f"fetched {stats.fetched} sources · {stats.items_considered} items ({stats.items_new} new)"
|
|
165
|
+
)
|
|
166
|
+
for outcome in stats.outcomes:
|
|
167
|
+
extra = f" HTTP {outcome.http_status}" if outcome.http_status else ""
|
|
168
|
+
detail = f" — {outcome.detail}" if outcome.detail else ""
|
|
169
|
+
print(f" {outcome.source_id}: {outcome.status}{extra}{detail}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _print_brief(briefing, cfg: AppConfig) -> None:
|
|
173
|
+
print(f"{briefing.title}")
|
|
174
|
+
print(coverage_line(briefing.coverage))
|
|
175
|
+
print(f"wrote {cfg.brief.output_dir / 'briefing.md'}")
|
|
176
|
+
print(f"wrote {cfg.brief.output_dir / 'briefing.json'}")
|
|
177
|
+
print(f"wrote {cfg.brief.output_dir / 'briefings.xml'}")
|
|
178
|
+
if cfg.brief.html:
|
|
179
|
+
print(f"wrote {cfg.brief.output_dir / 'index.html'}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _sources_check(cfg: AppConfig, *, live: bool) -> int:
|
|
183
|
+
sources = cfg.sources or parse_sources(load_yaml(bundled_sources_path()))
|
|
184
|
+
errors = 0
|
|
185
|
+
for source in sources:
|
|
186
|
+
problems: list[str] = []
|
|
187
|
+
if source.kind is SourceKind.GITHUB and not source.repo:
|
|
188
|
+
problems.append("missing repo")
|
|
189
|
+
if source.kind is not SourceKind.GITHUB and not source.url:
|
|
190
|
+
problems.append("missing url")
|
|
191
|
+
if source.url and not source.url.startswith(("http://", "https://")):
|
|
192
|
+
problems.append("url must be http(s)")
|
|
193
|
+
status = "ok" if not problems else "invalid"
|
|
194
|
+
if problems:
|
|
195
|
+
errors += 1
|
|
196
|
+
extra = f" ({'; '.join(problems)})" if problems else ""
|
|
197
|
+
target = source.repo or source.url or ""
|
|
198
|
+
print(f"{source.id:24} {source.kind.value:8} {status:8} {target}{extra}")
|
|
199
|
+
print(f"{len(sources)} sources · {len(sources) - errors} valid · {errors} errors")
|
|
200
|
+
if not live:
|
|
201
|
+
return 1 if errors else 0
|
|
202
|
+
|
|
203
|
+
client = PoliteClient(cfg.fetch, store=None)
|
|
204
|
+
failed = 0
|
|
205
|
+
try:
|
|
206
|
+
for source in sources:
|
|
207
|
+
if not source.enabled:
|
|
208
|
+
print(f"{source.id:24} skipped (disabled)")
|
|
209
|
+
continue
|
|
210
|
+
result = check_url(client, source.target())
|
|
211
|
+
mark = "ok" if result["ok"] else "FAIL"
|
|
212
|
+
if not result["ok"]:
|
|
213
|
+
failed += 1
|
|
214
|
+
print(f"{source.id:24} {mark:8} {result.get('status')} {result['detail']}")
|
|
215
|
+
finally:
|
|
216
|
+
client.close()
|
|
217
|
+
print(f"live check: {len(sources) - failed} reachable · {failed} failed")
|
|
218
|
+
return 1 if failed or errors else 0
|
shipwatch/config.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""YAML config, defaults, and the bundled source pack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from importlib import resources
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from shipwatch._version import __version__
|
|
14
|
+
from shipwatch.models import SourceKind, SourceSpec
|
|
15
|
+
|
|
16
|
+
DEFAULT_USER_AGENT = f"shipwatch/{__version__} (+https://github.com/andymccutcheon/shipwatch)"
|
|
17
|
+
DEFAULT_FREEWHIRR_BASE = "http://127.0.0.1:8787/v1"
|
|
18
|
+
DEFAULT_FREEWHIRR_MODEL = "freewhirr"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class LLMSettings:
|
|
23
|
+
base_url: str = DEFAULT_FREEWHIRR_BASE
|
|
24
|
+
model: str = DEFAULT_FREEWHIRR_MODEL
|
|
25
|
+
api_key_env: str = "SHIPWATCH_API_KEY"
|
|
26
|
+
timeout_seconds: float = 60.0
|
|
27
|
+
retries: int = 2
|
|
28
|
+
temperature: float = 0.2
|
|
29
|
+
|
|
30
|
+
def api_key(self) -> str:
|
|
31
|
+
for name in (self.api_key_env, "OPENROUTER_API_KEY", "OPENAI_API_KEY"):
|
|
32
|
+
value = os.environ.get(name, "").strip()
|
|
33
|
+
if value:
|
|
34
|
+
return value
|
|
35
|
+
return "not-used"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ScoringSettings:
|
|
40
|
+
novelty: float = 0.25
|
|
41
|
+
impact: float = 0.30
|
|
42
|
+
breadth: float = 0.20
|
|
43
|
+
recency: float = 0.25
|
|
44
|
+
pricing_boost: float = 0.15
|
|
45
|
+
deprecation_boost: float = 0.15
|
|
46
|
+
security_boost: float = 0.10
|
|
47
|
+
recency_half_life_days: float = 7.0
|
|
48
|
+
near_dup_threshold: float = 0.62
|
|
49
|
+
cluster_threshold: float = 0.28
|
|
50
|
+
non_shipment_penalty: float = 0.40
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class BriefSettings:
|
|
55
|
+
top_n: int = 12
|
|
56
|
+
since: str = "7d"
|
|
57
|
+
output_dir: Path = Path("out")
|
|
58
|
+
html: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class FetchSettings:
|
|
63
|
+
user_agent: str = DEFAULT_USER_AGENT
|
|
64
|
+
timeout_seconds: float = 30.0
|
|
65
|
+
respect_robots: bool = True
|
|
66
|
+
min_interval_seconds: float = 1.0
|
|
67
|
+
max_items_per_source: int = 40
|
|
68
|
+
github_token_env: str = "GITHUB_TOKEN"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class AppConfig:
|
|
73
|
+
config_path: Path | None = None
|
|
74
|
+
data_dir: Path = Path(".shipwatch")
|
|
75
|
+
sources_file: Path | None = None
|
|
76
|
+
sources: list[SourceSpec] = field(default_factory=list)
|
|
77
|
+
llm: LLMSettings = field(default_factory=LLMSettings)
|
|
78
|
+
scoring: ScoringSettings = field(default_factory=ScoringSettings)
|
|
79
|
+
brief: BriefSettings = field(default_factory=BriefSettings)
|
|
80
|
+
fetch: FetchSettings = field(default_factory=FetchSettings)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def db_path(self) -> Path:
|
|
84
|
+
return self.data_dir / "shipwatch.db"
|
|
85
|
+
|
|
86
|
+
def enabled_sources(self) -> list[SourceSpec]:
|
|
87
|
+
return [s for s in self.sources if s.enabled]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def bundled_sources_path() -> Path:
|
|
91
|
+
ref = resources.files("shipwatch").joinpath("data/sources.yaml")
|
|
92
|
+
return Path(str(ref))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def default_config_text() -> str:
|
|
96
|
+
return f"""# shipwatch.yaml
|
|
97
|
+
# Know what AI companies actually shipped this week.
|
|
98
|
+
|
|
99
|
+
data_dir: .shipwatch
|
|
100
|
+
sources_file: sources.yaml
|
|
101
|
+
|
|
102
|
+
llm:
|
|
103
|
+
# Default: Andy's free-first OpenRouter router (https://github.com/andymccutcheon/freewhirr)
|
|
104
|
+
# pip install freewhirr && freewhirr serve --port 8787
|
|
105
|
+
base_url: {DEFAULT_FREEWHIRR_BASE}
|
|
106
|
+
model: {DEFAULT_FREEWHIRR_MODEL}
|
|
107
|
+
api_key_env: SHIPWATCH_API_KEY
|
|
108
|
+
# OpenRouter (direct):
|
|
109
|
+
# base_url: https://openrouter.ai/api/v1
|
|
110
|
+
# model: meta-llama/llama-3.3-70b-instruct:free
|
|
111
|
+
# api_key_env: OPENROUTER_API_KEY
|
|
112
|
+
# OpenAI:
|
|
113
|
+
# base_url: https://api.openai.com/v1
|
|
114
|
+
# model: gpt-4o-mini
|
|
115
|
+
# api_key_env: OPENAI_API_KEY
|
|
116
|
+
retries: 2
|
|
117
|
+
temperature: 0.2
|
|
118
|
+
|
|
119
|
+
scoring:
|
|
120
|
+
weights:
|
|
121
|
+
novelty: 0.25
|
|
122
|
+
impact: 0.30
|
|
123
|
+
breadth: 0.20
|
|
124
|
+
recency: 0.25
|
|
125
|
+
boosts:
|
|
126
|
+
pricing_change: 0.15
|
|
127
|
+
deprecation: 0.15
|
|
128
|
+
security: 0.10
|
|
129
|
+
penalties:
|
|
130
|
+
non_shipment: 0.40
|
|
131
|
+
recency_half_life_days: 7
|
|
132
|
+
near_dup_threshold: 0.62
|
|
133
|
+
cluster_threshold: 0.28
|
|
134
|
+
|
|
135
|
+
brief:
|
|
136
|
+
top_n: 12
|
|
137
|
+
since: 7d
|
|
138
|
+
output_dir: out
|
|
139
|
+
html: false
|
|
140
|
+
|
|
141
|
+
fetch:
|
|
142
|
+
user_agent: "{DEFAULT_USER_AGENT}"
|
|
143
|
+
timeout_seconds: 30
|
|
144
|
+
respect_robots: true
|
|
145
|
+
min_interval_seconds: 1.0
|
|
146
|
+
max_items_per_source: 40
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def load_yaml(path: Path) -> dict[str, Any]:
|
|
151
|
+
if not path.exists():
|
|
152
|
+
return {}
|
|
153
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
154
|
+
return data if isinstance(data, dict) else {}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parse_sources(payload: Any) -> list[SourceSpec]:
|
|
158
|
+
rows = payload.get("sources", []) if isinstance(payload, dict) else payload
|
|
159
|
+
if not isinstance(rows, list):
|
|
160
|
+
raise ValueError("sources file must be a list or a mapping with a 'sources' key")
|
|
161
|
+
sources: list[SourceSpec] = []
|
|
162
|
+
for row in rows:
|
|
163
|
+
if not isinstance(row, dict):
|
|
164
|
+
raise ValueError("each source must be a mapping")
|
|
165
|
+
sources.append(
|
|
166
|
+
SourceSpec(
|
|
167
|
+
id=str(row["id"]),
|
|
168
|
+
name=str(row.get("name") or row["id"]),
|
|
169
|
+
kind=SourceKind(str(row["kind"])),
|
|
170
|
+
url=row.get("url"),
|
|
171
|
+
repo=row.get("repo"),
|
|
172
|
+
selector=row.get("selector"),
|
|
173
|
+
enabled=bool(row.get("enabled", True)),
|
|
174
|
+
tags=list(row.get("tags") or []),
|
|
175
|
+
notes=row.get("notes"),
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
return sources
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _weights(raw: dict[str, Any]) -> ScoringSettings:
|
|
182
|
+
weights = raw.get("weights") or {}
|
|
183
|
+
boosts = raw.get("boosts") or {}
|
|
184
|
+
penalties = raw.get("penalties") or {}
|
|
185
|
+
return ScoringSettings(
|
|
186
|
+
novelty=float(weights.get("novelty", 0.25)),
|
|
187
|
+
impact=float(weights.get("impact", 0.30)),
|
|
188
|
+
breadth=float(weights.get("breadth", 0.20)),
|
|
189
|
+
recency=float(weights.get("recency", 0.25)),
|
|
190
|
+
pricing_boost=float(boosts.get("pricing_change", 0.15)),
|
|
191
|
+
deprecation_boost=float(boosts.get("deprecation", 0.15)),
|
|
192
|
+
security_boost=float(boosts.get("security", 0.10)),
|
|
193
|
+
recency_half_life_days=float(raw.get("recency_half_life_days", 7)),
|
|
194
|
+
near_dup_threshold=float(raw.get("near_dup_threshold", 0.62)),
|
|
195
|
+
cluster_threshold=float(raw.get("cluster_threshold", 0.28)),
|
|
196
|
+
non_shipment_penalty=float(penalties.get("non_shipment", 0.40)),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def load_config(path: Path | None) -> AppConfig:
|
|
201
|
+
cfg = AppConfig(config_path=path)
|
|
202
|
+
data: dict[str, Any] = {}
|
|
203
|
+
if path and path.exists():
|
|
204
|
+
data = load_yaml(path)
|
|
205
|
+
root = path.parent
|
|
206
|
+
else:
|
|
207
|
+
root = Path.cwd()
|
|
208
|
+
|
|
209
|
+
cfg.data_dir = Path(
|
|
210
|
+
data.get("data_dir") or os.environ.get("SHIPWATCH_DATA_DIR") or cfg.data_dir
|
|
211
|
+
)
|
|
212
|
+
if not cfg.data_dir.is_absolute():
|
|
213
|
+
cfg.data_dir = (root / cfg.data_dir).resolve() if path else Path.cwd() / cfg.data_dir
|
|
214
|
+
|
|
215
|
+
sources_file = data.get("sources_file")
|
|
216
|
+
inline = data.get("sources")
|
|
217
|
+
if sources_file:
|
|
218
|
+
src_path = Path(sources_file)
|
|
219
|
+
if not src_path.is_absolute():
|
|
220
|
+
src_path = root / src_path
|
|
221
|
+
cfg.sources_file = src_path
|
|
222
|
+
cfg.sources = parse_sources(load_yaml(src_path))
|
|
223
|
+
elif inline:
|
|
224
|
+
cfg.sources = parse_sources(inline)
|
|
225
|
+
else:
|
|
226
|
+
cfg.sources = parse_sources(load_yaml(bundled_sources_path()))
|
|
227
|
+
|
|
228
|
+
llm = data.get("llm") or {}
|
|
229
|
+
cfg.llm = LLMSettings(
|
|
230
|
+
base_url=os.environ.get("SHIPWATCH_BASE_URL")
|
|
231
|
+
or llm.get("base_url")
|
|
232
|
+
or DEFAULT_FREEWHIRR_BASE,
|
|
233
|
+
model=os.environ.get("SHIPWATCH_MODEL") or llm.get("model") or DEFAULT_FREEWHIRR_MODEL,
|
|
234
|
+
api_key_env=llm.get("api_key_env") or "SHIPWATCH_API_KEY",
|
|
235
|
+
timeout_seconds=float(llm.get("timeout_seconds", 60)),
|
|
236
|
+
retries=int(llm.get("retries", 2)),
|
|
237
|
+
temperature=float(llm.get("temperature", 0.2)),
|
|
238
|
+
)
|
|
239
|
+
cfg.scoring = _weights(data.get("scoring") or {})
|
|
240
|
+
brief = data.get("brief") or {}
|
|
241
|
+
cfg.brief = BriefSettings(
|
|
242
|
+
top_n=int(brief.get("top_n", 12)),
|
|
243
|
+
since=str(brief.get("since", "7d")),
|
|
244
|
+
output_dir=Path(brief.get("output_dir") or "out"),
|
|
245
|
+
html=bool(brief.get("html", False)),
|
|
246
|
+
)
|
|
247
|
+
if not cfg.brief.output_dir.is_absolute():
|
|
248
|
+
cfg.brief.output_dir = (
|
|
249
|
+
(root / cfg.brief.output_dir).resolve() if path else Path.cwd() / cfg.brief.output_dir
|
|
250
|
+
)
|
|
251
|
+
fetch = data.get("fetch") or {}
|
|
252
|
+
cfg.fetch = FetchSettings(
|
|
253
|
+
user_agent=fetch.get("user_agent") or DEFAULT_USER_AGENT,
|
|
254
|
+
timeout_seconds=float(fetch.get("timeout_seconds", 30)),
|
|
255
|
+
respect_robots=bool(fetch.get("respect_robots", True)),
|
|
256
|
+
min_interval_seconds=float(fetch.get("min_interval_seconds", 1.0)),
|
|
257
|
+
max_items_per_source=int(fetch.get("max_items_per_source", 40)),
|
|
258
|
+
github_token_env=fetch.get("github_token_env") or "GITHUB_TOKEN",
|
|
259
|
+
)
|
|
260
|
+
return cfg
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def write_init_files(directory: Path, *, force: bool = False) -> list[Path]:
|
|
264
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
written: list[Path] = []
|
|
266
|
+
config_path = directory / "shipwatch.yaml"
|
|
267
|
+
sources_path = directory / "sources.yaml"
|
|
268
|
+
if config_path.exists() and not force:
|
|
269
|
+
raise FileExistsError(f"{config_path} already exists (pass --force to overwrite)")
|
|
270
|
+
config_path.write_text(default_config_text(), encoding="utf-8")
|
|
271
|
+
written.append(config_path)
|
|
272
|
+
pack = bundled_sources_path().read_text(encoding="utf-8")
|
|
273
|
+
if sources_path.exists() and not force:
|
|
274
|
+
raise FileExistsError(f"{sources_path} already exists (pass --force to overwrite)")
|
|
275
|
+
sources_path.write_text(pack, encoding="utf-8")
|
|
276
|
+
written.append(sources_path)
|
|
277
|
+
return written
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Sample source pack (~26 well-known AI and developer-tool changelogs).
|
|
2
|
+
# URLs were fetched successfully on 2026-10-11 with the shipwatch user agent.
|
|
3
|
+
# Prefer RSS/Atom and GitHub Releases; HTML pages are a fallback and may be
|
|
4
|
+
# thin when a site is client-rendered.
|
|
5
|
+
#
|
|
6
|
+
# Edit freely. Disable a row with enabled: false.
|
|
7
|
+
|
|
8
|
+
sources:
|
|
9
|
+
- id: openai-news
|
|
10
|
+
name: OpenAI News
|
|
11
|
+
kind: rss
|
|
12
|
+
url: https://openai.com/news/rss.xml
|
|
13
|
+
tags: [openai, models]
|
|
14
|
+
|
|
15
|
+
- id: openai-api
|
|
16
|
+
name: OpenAI API Changelog
|
|
17
|
+
kind: html
|
|
18
|
+
url: https://developers.openai.com/api/docs/changelog
|
|
19
|
+
tags: [openai, api]
|
|
20
|
+
|
|
21
|
+
- id: anthropic-news
|
|
22
|
+
name: Anthropic News
|
|
23
|
+
kind: html
|
|
24
|
+
url: https://www.anthropic.com/news
|
|
25
|
+
tags: [anthropic, models]
|
|
26
|
+
|
|
27
|
+
- id: anthropic-release-notes
|
|
28
|
+
name: Anthropic Release Notes
|
|
29
|
+
kind: html
|
|
30
|
+
url: https://docs.anthropic.com/en/release-notes/overview
|
|
31
|
+
tags: [anthropic, api]
|
|
32
|
+
notes: Redirects to platform.claude.com as of 2026-10-11.
|
|
33
|
+
|
|
34
|
+
- id: google-ai-blog
|
|
35
|
+
name: Google AI Blog
|
|
36
|
+
kind: rss
|
|
37
|
+
url: https://blog.google/technology/ai/rss/
|
|
38
|
+
tags: [google, gemini]
|
|
39
|
+
|
|
40
|
+
- id: gemini-api
|
|
41
|
+
name: Gemini API Changelog
|
|
42
|
+
kind: html
|
|
43
|
+
url: https://ai.google.dev/gemini-api/docs/changelog
|
|
44
|
+
tags: [google, api]
|
|
45
|
+
|
|
46
|
+
- id: gemini-docs-sitemap
|
|
47
|
+
name: Gemini docs (new pages)
|
|
48
|
+
kind: sitemap
|
|
49
|
+
url: https://ai.google.dev/sitemap.xml
|
|
50
|
+
tags: [google, docs]
|
|
51
|
+
notes: Optional new-page detector. High volume; disable if noisy.
|
|
52
|
+
|
|
53
|
+
- id: mistral-news
|
|
54
|
+
name: Mistral News
|
|
55
|
+
kind: rss
|
|
56
|
+
url: https://mistral.ai/news/rss
|
|
57
|
+
tags: [mistral, models]
|
|
58
|
+
|
|
59
|
+
- id: cursor
|
|
60
|
+
name: Cursor Changelog
|
|
61
|
+
kind: html
|
|
62
|
+
url: https://cursor.com/changelog
|
|
63
|
+
tags: [cursor, editor]
|
|
64
|
+
notes: Marketing changelog; may be JS-rendered.
|
|
65
|
+
|
|
66
|
+
- id: vercel
|
|
67
|
+
name: Vercel
|
|
68
|
+
kind: rss
|
|
69
|
+
url: https://vercel.com/atom
|
|
70
|
+
tags: [vercel, platform]
|
|
71
|
+
|
|
72
|
+
- id: huggingface
|
|
73
|
+
name: Hugging Face Blog
|
|
74
|
+
kind: rss
|
|
75
|
+
url: https://huggingface.co/blog/feed.xml
|
|
76
|
+
tags: [huggingface, models]
|
|
77
|
+
|
|
78
|
+
- id: langchain
|
|
79
|
+
name: LangChain
|
|
80
|
+
kind: github
|
|
81
|
+
repo: langchain-ai/langchain
|
|
82
|
+
tags: [langchain, framework]
|
|
83
|
+
|
|
84
|
+
- id: openrouter
|
|
85
|
+
name: OpenRouter API Changelog
|
|
86
|
+
kind: html
|
|
87
|
+
url: https://openrouter.ai/docs/changelog
|
|
88
|
+
tags: [openrouter, api]
|
|
89
|
+
|
|
90
|
+
- id: llamaindex
|
|
91
|
+
name: LlamaIndex
|
|
92
|
+
kind: github
|
|
93
|
+
repo: run-llama/llama_index
|
|
94
|
+
tags: [llamaindex, framework]
|
|
95
|
+
|
|
96
|
+
- id: groq
|
|
97
|
+
name: Groq Changelog
|
|
98
|
+
kind: html
|
|
99
|
+
url: https://console.groq.com/docs/changelog
|
|
100
|
+
tags: [groq, inference]
|
|
101
|
+
|
|
102
|
+
- id: cohere
|
|
103
|
+
name: Cohere Changelog
|
|
104
|
+
kind: html
|
|
105
|
+
url: https://docs.cohere.com/changelog
|
|
106
|
+
tags: [cohere, models]
|
|
107
|
+
|
|
108
|
+
- id: pinecone
|
|
109
|
+
name: Pinecone Release Notes
|
|
110
|
+
kind: html
|
|
111
|
+
url: https://docs.pinecone.io/release-notes/2026
|
|
112
|
+
tags: [pinecone, retrieval]
|
|
113
|
+
|
|
114
|
+
- id: replicate
|
|
115
|
+
name: Replicate Changelog
|
|
116
|
+
kind: html
|
|
117
|
+
url: https://replicate.com/changelog
|
|
118
|
+
tags: [replicate, inference]
|
|
119
|
+
|
|
120
|
+
- id: cloudflare
|
|
121
|
+
name: Cloudflare Blog
|
|
122
|
+
kind: rss
|
|
123
|
+
url: https://blog.cloudflare.com/rss/
|
|
124
|
+
tags: [cloudflare, platform]
|
|
125
|
+
|
|
126
|
+
- id: nextjs
|
|
127
|
+
name: Next.js
|
|
128
|
+
kind: github
|
|
129
|
+
repo: vercel/next.js
|
|
130
|
+
tags: [vercel, nextjs]
|
|
131
|
+
|
|
132
|
+
- id: fastapi
|
|
133
|
+
name: FastAPI
|
|
134
|
+
kind: github
|
|
135
|
+
repo: fastapi/fastapi
|
|
136
|
+
tags: [python, api]
|
|
137
|
+
|
|
138
|
+
- id: pydantic
|
|
139
|
+
name: Pydantic
|
|
140
|
+
kind: github
|
|
141
|
+
repo: pydantic/pydantic
|
|
142
|
+
tags: [python, validation]
|
|
143
|
+
|
|
144
|
+
- id: supabase
|
|
145
|
+
name: Supabase Changelog
|
|
146
|
+
kind: html
|
|
147
|
+
url: https://supabase.com/changelog
|
|
148
|
+
tags: [supabase, platform]
|
|
149
|
+
|
|
150
|
+
- id: github-changelog
|
|
151
|
+
name: GitHub Changelog
|
|
152
|
+
kind: rss
|
|
153
|
+
url: https://github.blog/changelog/feed/
|
|
154
|
+
tags: [github, platform]
|
|
155
|
+
|
|
156
|
+
- id: xai
|
|
157
|
+
name: xAI Release Notes
|
|
158
|
+
kind: html
|
|
159
|
+
url: https://docs.x.ai/docs/release-notes
|
|
160
|
+
tags: [xai, models]
|
|
161
|
+
|
|
162
|
+
- id: deepseek
|
|
163
|
+
name: DeepSeek API Updates
|
|
164
|
+
kind: html
|
|
165
|
+
url: https://api-docs.deepseek.com/updates
|
|
166
|
+
tags: [deepseek, models]
|
|
167
|
+
|
|
168
|
+
- id: together
|
|
169
|
+
name: Together AI Changelog
|
|
170
|
+
kind: html
|
|
171
|
+
url: https://docs.together.ai/docs/changelog
|
|
172
|
+
tags: [together, inference]
|