@m13v/s4l 1.6.197-rc.10
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.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""seed_search_queries.py — convert a project's seeded search TOPICS into a
|
|
3
|
+
cold-start QUERY bank (>=30 real X advanced-search strings) at setup time.
|
|
4
|
+
|
|
5
|
+
Why
|
|
6
|
+
---
|
|
7
|
+
The Twitter cycle's deterministic Phase 1 (scripts/qualified_query_bank.py)
|
|
8
|
+
replays a project's historically *qualified* queries — distinct X search
|
|
9
|
+
strings that already produced an engaged post. A brand-new project has zero
|
|
10
|
+
post history, so that bank is empty and the cycle falls back to ONE crude
|
|
11
|
+
query (the single picked topic + "-filter:replies"). That's the "only one
|
|
12
|
+
search query" cold-start symptom the user hit on chosenhq.
|
|
13
|
+
|
|
14
|
+
This script fixes the supply side: it reads the project's ACTIVE topics from
|
|
15
|
+
project_search_topics, fans each topic out into several distinct X queries via
|
|
16
|
+
the SAME Claude drafting prompt invent_topics.py uses (reused, not duplicated),
|
|
17
|
+
optionally supply-tests them against the live browser harness, and persists the
|
|
18
|
+
survivors into project_search_queries with source='seed'. The bank backfills
|
|
19
|
+
from these active rows when the proven+invented set is still thin, so a fresh
|
|
20
|
+
project runs ~30 queries on day one and the seed rows fade as real winners
|
|
21
|
+
accumulate.
|
|
22
|
+
|
|
23
|
+
Reuse
|
|
24
|
+
-----
|
|
25
|
+
All drafting / parsing / dedup / supply-test logic is imported from
|
|
26
|
+
invent_topics.py (build_query_prompt, extract_queries, call_claude,
|
|
27
|
+
normalize_query, load_existing_query_cores, dedup_queries, supply_test,
|
|
28
|
+
harness_alive). This file is only the orchestration + persistence layer.
|
|
29
|
+
|
|
30
|
+
Topics + queries are read/written through the website API (/api/v1/*) per the
|
|
31
|
+
"no direct SQL in pipeline Python" rule.
|
|
32
|
+
|
|
33
|
+
CLI:
|
|
34
|
+
python3 scripts/seed_search_queries.py --project chosenhq
|
|
35
|
+
python3 scripts/seed_search_queries.py --project fazm --target 30
|
|
36
|
+
python3 scripts/seed_search_queries.py --project fazm --supply-test off
|
|
37
|
+
python3 scripts/seed_search_queries.py --project fazm --dry-run
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import argparse
|
|
42
|
+
import json
|
|
43
|
+
import math
|
|
44
|
+
import os
|
|
45
|
+
import sys
|
|
46
|
+
|
|
47
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
48
|
+
|
|
49
|
+
from http_api import api_get, api_post # noqa: E402
|
|
50
|
+
from pick_project import load_config # noqa: E402
|
|
51
|
+
from invent_topics import ( # noqa: E402
|
|
52
|
+
CDP_PORT,
|
|
53
|
+
FRESHNESS_HOURS,
|
|
54
|
+
build_query_prompt,
|
|
55
|
+
call_claude,
|
|
56
|
+
dedup_queries,
|
|
57
|
+
extract_queries,
|
|
58
|
+
harness_alive,
|
|
59
|
+
load_existing_query_cores,
|
|
60
|
+
normalize_query,
|
|
61
|
+
supply_test,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# How many real queries we want a fresh project to launch with. The user's
|
|
65
|
+
# target: "convert these search topics into at least 30 search queries
|
|
66
|
+
# altogether". 30 is enough to fan a typical 6-12 topic universe across
|
|
67
|
+
# multiple angles without exploding setup cost.
|
|
68
|
+
DEFAULT_TARGET = 30
|
|
69
|
+
|
|
70
|
+
# Per-topic draft cap so a project with very few topics doesn't ask Claude for
|
|
71
|
+
# 30 queries off one topic (they'd collapse into near-dupes). With >=4 topics
|
|
72
|
+
# the ceil(target/topics) math stays under this anyway.
|
|
73
|
+
MAX_PER_TOPIC = 8
|
|
74
|
+
MIN_PER_TOPIC = 2
|
|
75
|
+
|
|
76
|
+
# Query-draft Claude call timeout (one call per topic).
|
|
77
|
+
DRAFT_TIMEOUT_SEC = 240
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_active_topics(project: str) -> list[str]:
|
|
81
|
+
"""Active topics for a project from project_search_topics (DB universe)."""
|
|
82
|
+
resp = api_get(
|
|
83
|
+
"/api/v1/project-search-topics",
|
|
84
|
+
{"project": project, "status": "active"},
|
|
85
|
+
)
|
|
86
|
+
data = (resp or {}).get("data") or {}
|
|
87
|
+
rows = data.get("topics") or []
|
|
88
|
+
out, seen = [], set()
|
|
89
|
+
for r in rows:
|
|
90
|
+
t = (r.get("topic") or "").strip()
|
|
91
|
+
if t and t.lower() not in seen:
|
|
92
|
+
seen.add(t.lower())
|
|
93
|
+
out.append(t)
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _load_existing_seed_cores(project: str) -> set[str]:
|
|
98
|
+
"""Normalized cores of seed queries already persisted for this project, so
|
|
99
|
+
re-running the seeder is idempotent and never double-drafts the same core."""
|
|
100
|
+
try:
|
|
101
|
+
resp = api_get(
|
|
102
|
+
"/api/v1/project-search-queries",
|
|
103
|
+
{"project": project, "status": "all"},
|
|
104
|
+
)
|
|
105
|
+
except SystemExit as exc:
|
|
106
|
+
print(f"[seed_search_queries] existing-seed read failed for "
|
|
107
|
+
f"{project!r}: {exc} (proceeding without seed dedup)",
|
|
108
|
+
file=sys.stderr)
|
|
109
|
+
return set()
|
|
110
|
+
data = (resp or {}).get("data") or {}
|
|
111
|
+
rows = data.get("queries") or []
|
|
112
|
+
return {normalize_query(r.get("query") or "") for r in rows if r.get("query")}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _fetch_active_queries(project: str) -> list[dict]:
|
|
116
|
+
"""The project's current ACTIVE seed-query bank (query + topic), so a caller
|
|
117
|
+
can show the user exactly what the cycle will fan out over. Best-effort:
|
|
118
|
+
returns [] on read failure."""
|
|
119
|
+
try:
|
|
120
|
+
resp = api_get(
|
|
121
|
+
"/api/v1/project-search-queries",
|
|
122
|
+
{"project": project, "status": "active"},
|
|
123
|
+
)
|
|
124
|
+
except SystemExit:
|
|
125
|
+
return []
|
|
126
|
+
data = (resp or {}).get("data") or {}
|
|
127
|
+
out = []
|
|
128
|
+
for r in (data.get("queries") or []):
|
|
129
|
+
q = (r.get("query") or "").strip()
|
|
130
|
+
if q:
|
|
131
|
+
out.append({"query": q, "topic": (r.get("topic") or "").strip()})
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _load_provided_queries(path: str) -> list[tuple[str, str]]:
|
|
136
|
+
"""Read AGENT-SUPPLIED queries from a JSON file: returns (query, topic) pairs.
|
|
137
|
+
|
|
138
|
+
Accepts either {"queries": [...]} or a bare top-level list. Each item may be
|
|
139
|
+
a string (topic left blank) or an object {"query": ..., "topic": ...}.
|
|
140
|
+
This is the claude-free seed path: the in-session agent already expanded the
|
|
141
|
+
topics into queries, so there is nothing to draft."""
|
|
142
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
143
|
+
raw = json.load(fh)
|
|
144
|
+
items = raw.get("queries") if isinstance(raw, dict) else raw
|
|
145
|
+
out: list[tuple[str, str]] = []
|
|
146
|
+
seen: set[str] = set()
|
|
147
|
+
for it in (items or []):
|
|
148
|
+
if isinstance(it, str):
|
|
149
|
+
q, t = it.strip(), ""
|
|
150
|
+
elif isinstance(it, dict):
|
|
151
|
+
q, t = (it.get("query") or "").strip(), (it.get("topic") or "").strip()
|
|
152
|
+
else:
|
|
153
|
+
continue
|
|
154
|
+
if not q:
|
|
155
|
+
continue
|
|
156
|
+
core = normalize_query(q)
|
|
157
|
+
if core in seen:
|
|
158
|
+
continue
|
|
159
|
+
seen.add(core)
|
|
160
|
+
out.append((q, t))
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _find_project(cfg: dict, name: str) -> dict | None:
|
|
165
|
+
for p in cfg.get("projects", []):
|
|
166
|
+
if (p.get("name") or "").strip().lower() == name.strip().lower():
|
|
167
|
+
return p
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _persist(project: str, query: str, topic: str,
|
|
172
|
+
supply_tested: bool, tweets_found, dry_run: bool) -> str:
|
|
173
|
+
"""POST one seed query; returns the action ('inserted'/'updated'/'fail')."""
|
|
174
|
+
if dry_run:
|
|
175
|
+
return "dry"
|
|
176
|
+
try:
|
|
177
|
+
resp = api_post(
|
|
178
|
+
"/api/v1/project-search-queries",
|
|
179
|
+
body={
|
|
180
|
+
"project": project,
|
|
181
|
+
"query": query,
|
|
182
|
+
"topic": topic,
|
|
183
|
+
"source": "seed",
|
|
184
|
+
"status": "active",
|
|
185
|
+
"supply_tested": supply_tested,
|
|
186
|
+
"tweets_found": tweets_found,
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
except SystemExit as e:
|
|
190
|
+
print(f"[FAIL] {project}: {query!r}: {e}", file=sys.stderr)
|
|
191
|
+
return "fail"
|
|
192
|
+
data = (resp or {}).get("data") or resp or {}
|
|
193
|
+
return data.get("action") or "unknown"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main() -> int:
|
|
197
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
198
|
+
ap.add_argument("--project", required=True,
|
|
199
|
+
help="Project name (config.json casing).")
|
|
200
|
+
ap.add_argument("--target", type=int, default=DEFAULT_TARGET,
|
|
201
|
+
help=f"Total queries to aim for (default {DEFAULT_TARGET}).")
|
|
202
|
+
ap.add_argument("--supply-test", choices=["auto", "on", "off"],
|
|
203
|
+
default="auto",
|
|
204
|
+
help="auto (default): supply-test only if the harness is "
|
|
205
|
+
"up; on: require it; off: skip and seed all drafts.")
|
|
206
|
+
ap.add_argument("--dry-run", action="store_true",
|
|
207
|
+
help="Draft + (maybe) supply-test, but do NOT persist.")
|
|
208
|
+
ap.add_argument("--emit-json", action="store_true",
|
|
209
|
+
help="After seeding, print the project's full ACTIVE seed-query "
|
|
210
|
+
"bank as JSON on a sentinel line (===QUERIES_JSON===) so a "
|
|
211
|
+
"caller (e.g. the MCP setup tool) can hand the queries back "
|
|
212
|
+
"to the user.")
|
|
213
|
+
ap.add_argument("--queries-json",
|
|
214
|
+
help="Path to a JSON file of AGENT-SUPPLIED queries to seed "
|
|
215
|
+
"directly, bypassing the `claude -p` drafting loop entirely. "
|
|
216
|
+
"Shape: {\"queries\":[{\"query\":\"...\",\"topic\":\"...\"}]} "
|
|
217
|
+
"or a bare list of strings. Use this from the in-session MCP "
|
|
218
|
+
"setup path so the seed step never depends on the claude CLI.")
|
|
219
|
+
args = ap.parse_args()
|
|
220
|
+
|
|
221
|
+
cfg = load_config()
|
|
222
|
+
project_entry = _find_project(cfg, args.project)
|
|
223
|
+
if not project_entry:
|
|
224
|
+
print(f"seed_search_queries: project {args.project!r} not in config.json",
|
|
225
|
+
file=sys.stderr)
|
|
226
|
+
return 2
|
|
227
|
+
# canonical name as stored
|
|
228
|
+
project = (project_entry.get("name") or args.project).strip()
|
|
229
|
+
|
|
230
|
+
target = max(1, args.target)
|
|
231
|
+
|
|
232
|
+
# ---- Agent-supplied path (claude-free) ------------------------------
|
|
233
|
+
# When the caller (the in-session MCP setup tool) hands us queries the agent
|
|
234
|
+
# already expanded from the topics, we skip the `claude -p` drafting loop
|
|
235
|
+
# entirely and just dedup + (maybe) supply-test + persist them. This is the
|
|
236
|
+
# single setup path on machines without the claude CLI. (2026-06-19)
|
|
237
|
+
if args.queries_json:
|
|
238
|
+
topics = _load_active_topics(project) # best-effort, summary only
|
|
239
|
+
provided = _load_provided_queries(args.queries_json)
|
|
240
|
+
existing = (load_existing_query_cores(project)
|
|
241
|
+
| _load_existing_seed_cores(project))
|
|
242
|
+
drafted = [(q, t) for q, t in provided
|
|
243
|
+
if normalize_query(q) not in existing]
|
|
244
|
+
active_now = len(_fetch_active_queries(project))
|
|
245
|
+
print(f"seed_search_queries: project={project!r} mode=agent-supplied "
|
|
246
|
+
f"provided={len(provided)} new={len(drafted)} "
|
|
247
|
+
f"active_now={active_now} (no claude)", file=sys.stderr)
|
|
248
|
+
if not drafted:
|
|
249
|
+
# Everything provided was already seeded (or nothing usable) —
|
|
250
|
+
# healthy no-op: re-emit the live bank and succeed.
|
|
251
|
+
print(
|
|
252
|
+
f"seed_search_queries: project={project} topics={len(topics)} "
|
|
253
|
+
f"drafted=0 supply_ran=0 dropped_zero=0 seeded=0 inserted=0 "
|
|
254
|
+
f"updated=0 failed=0 (agent_supplied_nothing_new)"
|
|
255
|
+
)
|
|
256
|
+
if args.emit_json:
|
|
257
|
+
queries = _fetch_active_queries(project)
|
|
258
|
+
print("===QUERIES_JSON===")
|
|
259
|
+
print(json.dumps({"project": project, "count": len(queries),
|
|
260
|
+
"queries": queries}))
|
|
261
|
+
return 0
|
|
262
|
+
else:
|
|
263
|
+
topics = _load_active_topics(project)
|
|
264
|
+
if not topics:
|
|
265
|
+
print(f"seed_search_queries: no active topics for {project!r} — seed "
|
|
266
|
+
f"topics first (scripts/seed_search_topics.py).", file=sys.stderr)
|
|
267
|
+
return 3
|
|
268
|
+
|
|
269
|
+
# Idempotency: aim for `target` TOTAL active queries, not `target` NEW per
|
|
270
|
+
# run. A project that already has >= target active seed queries needs no
|
|
271
|
+
# drafting — re-running reseed just re-emits the existing bank instead of
|
|
272
|
+
# ballooning it. Otherwise we only draft the shortfall. (2026-06-04)
|
|
273
|
+
active_now = len(_fetch_active_queries(project))
|
|
274
|
+
need = max(0, target - active_now)
|
|
275
|
+
per_topic = math.ceil(need / len(topics)) if need else 0
|
|
276
|
+
per_topic = max(MIN_PER_TOPIC, min(MAX_PER_TOPIC, per_topic)) if need else 0
|
|
277
|
+
|
|
278
|
+
# Dedup against (a) every query EVER attempted for this project and (b) seed
|
|
279
|
+
# queries already persisted. Accumulates as we go so topics don't overlap.
|
|
280
|
+
avoid_cores = load_existing_query_cores(project) | _load_existing_seed_cores(project)
|
|
281
|
+
|
|
282
|
+
print(f"seed_search_queries: project={project!r} topics={len(topics)} "
|
|
283
|
+
f"target={target} active_now={active_now} need={need} "
|
|
284
|
+
f"per_topic={per_topic} existing_cores={len(avoid_cores)}",
|
|
285
|
+
file=sys.stderr)
|
|
286
|
+
|
|
287
|
+
# --- Draft queries topic by topic ------------------------------------
|
|
288
|
+
drafted = [] # (query, topic)
|
|
289
|
+
for topic in topics:
|
|
290
|
+
if need <= 0 or len({normalize_query(q) for q, _ in drafted}) >= need:
|
|
291
|
+
break
|
|
292
|
+
prompt = build_query_prompt(
|
|
293
|
+
project_entry, topic, per_topic, avoid_queries=avoid_cores
|
|
294
|
+
)
|
|
295
|
+
try:
|
|
296
|
+
out = call_claude(prompt, timeout_sec=DRAFT_TIMEOUT_SEC)
|
|
297
|
+
except SystemExit as e:
|
|
298
|
+
print(f"[seed_search_queries] draft failed for topic {topic!r}: {e}",
|
|
299
|
+
file=sys.stderr)
|
|
300
|
+
continue
|
|
301
|
+
qs = extract_queries(out, per_topic)
|
|
302
|
+
new_qs, dupes = dedup_queries(qs, avoid_cores)
|
|
303
|
+
for q in new_qs:
|
|
304
|
+
avoid_cores.add(normalize_query(q))
|
|
305
|
+
drafted.append((q, topic))
|
|
306
|
+
print(f" topic={topic!r}: drafted={len(qs)} new={len(new_qs)} "
|
|
307
|
+
f"dupes={len(dupes)}", file=sys.stderr)
|
|
308
|
+
|
|
309
|
+
if not drafted:
|
|
310
|
+
# Two cases: (a) the bank already meets target (need==0) — that's a
|
|
311
|
+
# healthy no-op, re-emit the existing bank and succeed; (b) we wanted
|
|
312
|
+
# queries but drafting produced none (every draft failed/duped) — that's
|
|
313
|
+
# a real failure.
|
|
314
|
+
if need <= 0:
|
|
315
|
+
print(f"seed_search_queries: project={project} already has "
|
|
316
|
+
f"{active_now} active queries (>= target {target}); nothing to "
|
|
317
|
+
f"draft.", file=sys.stderr)
|
|
318
|
+
print(
|
|
319
|
+
f"seed_search_queries: project={project} topics={len(topics)} "
|
|
320
|
+
f"drafted=0 supply_ran=0 dropped_zero=0 seeded=0 inserted=0 "
|
|
321
|
+
f"updated=0 failed=0 (already_full)"
|
|
322
|
+
)
|
|
323
|
+
if args.emit_json:
|
|
324
|
+
queries = _fetch_active_queries(project)
|
|
325
|
+
print("===QUERIES_JSON===")
|
|
326
|
+
print(json.dumps({"project": project, "count": len(queries),
|
|
327
|
+
"queries": queries}))
|
|
328
|
+
return 0
|
|
329
|
+
print("seed_search_queries: drafted 0 queries — nothing to seed.",
|
|
330
|
+
file=sys.stderr)
|
|
331
|
+
return 1
|
|
332
|
+
|
|
333
|
+
# --- Decide whether to supply-test -----------------------------------
|
|
334
|
+
do_supply = False
|
|
335
|
+
if args.supply_test == "on":
|
|
336
|
+
do_supply = True
|
|
337
|
+
elif args.supply_test == "auto":
|
|
338
|
+
do_supply = harness_alive(CDP_PORT)
|
|
339
|
+
if args.supply_test == "on" and not harness_alive(CDP_PORT):
|
|
340
|
+
print("seed_search_queries: --supply-test on but harness not reachable "
|
|
341
|
+
f"on :{CDP_PORT}", file=sys.stderr)
|
|
342
|
+
return 4
|
|
343
|
+
|
|
344
|
+
supply_map: dict[str, int] = {}
|
|
345
|
+
supply_ran = False
|
|
346
|
+
if do_supply:
|
|
347
|
+
# Group by topic so supply_test gets a coherent (topic, queries) batch.
|
|
348
|
+
by_topic: dict[str, list[str]] = {}
|
|
349
|
+
for q, t in drafted:
|
|
350
|
+
by_topic.setdefault(t, []).append(q)
|
|
351
|
+
for t, qs in by_topic.items():
|
|
352
|
+
tested, results = supply_test(project, t, qs,
|
|
353
|
+
freshness_hours=FRESHNESS_HOURS)
|
|
354
|
+
if not tested:
|
|
355
|
+
# Lock timeout / browser down: do NOT treat as zero supply.
|
|
356
|
+
print(f" supply-test topic={t!r}: not tested (harness "
|
|
357
|
+
f"unavailable) — keeping queries untested",
|
|
358
|
+
file=sys.stderr)
|
|
359
|
+
continue
|
|
360
|
+
supply_ran = True
|
|
361
|
+
for r in results:
|
|
362
|
+
supply_map[normalize_query(r.get("query") or "")] = int(
|
|
363
|
+
r.get("tweets_found") or 0
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# --- Persist ----------------------------------------------------------
|
|
367
|
+
# When supply ran, drop zero-supply queries (they surfaced nothing fresh),
|
|
368
|
+
# but never let the drop take us below MIN_KEEP — a thin real bank beats an
|
|
369
|
+
# empty one for cold start. When supply did NOT run, seed everything
|
|
370
|
+
# untested (the bank still beats the 1-query fallback).
|
|
371
|
+
MIN_KEEP = max(1, target // 2)
|
|
372
|
+
to_seed: list[tuple[str, str, bool, object]] = []
|
|
373
|
+
dropped_zero = 0
|
|
374
|
+
for q, t in drafted:
|
|
375
|
+
core = normalize_query(q)
|
|
376
|
+
tested = supply_ran and core in supply_map
|
|
377
|
+
tw = supply_map.get(core) if tested else None
|
|
378
|
+
if supply_ran and tested and tw == 0:
|
|
379
|
+
dropped_zero += 1
|
|
380
|
+
continue
|
|
381
|
+
to_seed.append((q, t, bool(tested), tw))
|
|
382
|
+
|
|
383
|
+
if supply_ran and len(to_seed) < MIN_KEEP:
|
|
384
|
+
# Too aggressive — restore the highest-supply zeros... actually all were
|
|
385
|
+
# zero, so restore drafted order until MIN_KEEP. Keep them as tested=0.
|
|
386
|
+
restored = 0
|
|
387
|
+
have = {normalize_query(q) for q, _, _, _ in to_seed}
|
|
388
|
+
for q, t in drafted:
|
|
389
|
+
if restored and len(to_seed) >= MIN_KEEP:
|
|
390
|
+
break
|
|
391
|
+
core = normalize_query(q)
|
|
392
|
+
if core in have:
|
|
393
|
+
continue
|
|
394
|
+
to_seed.append((q, t, True, supply_map.get(core, 0)))
|
|
395
|
+
have.add(core)
|
|
396
|
+
restored += 1
|
|
397
|
+
print(f" supply-test dropped too many; restored {restored} to meet "
|
|
398
|
+
f"MIN_KEEP={MIN_KEEP}", file=sys.stderr)
|
|
399
|
+
|
|
400
|
+
inserted = updated = failed = 0
|
|
401
|
+
for q, t, tested, tw in to_seed:
|
|
402
|
+
action = _persist(project, q, t, tested, tw, args.dry_run)
|
|
403
|
+
if action == "inserted":
|
|
404
|
+
inserted += 1
|
|
405
|
+
elif action == "updated":
|
|
406
|
+
updated += 1
|
|
407
|
+
elif action == "fail":
|
|
408
|
+
failed += 1
|
|
409
|
+
|
|
410
|
+
# Machine-parseable summary line (consumed by mcp/src/index.ts setup hook).
|
|
411
|
+
print(
|
|
412
|
+
f"seed_search_queries: project={project} topics={len(topics)} "
|
|
413
|
+
f"drafted={len(drafted)} supply_ran={int(supply_ran)} "
|
|
414
|
+
f"dropped_zero={dropped_zero} seeded={inserted + updated} "
|
|
415
|
+
f"inserted={inserted} updated={updated} failed={failed}"
|
|
416
|
+
+ (" [dry-run]" if args.dry_run else "")
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Hand the resulting bank back to the caller (MCP setup tool) so it can show
|
|
420
|
+
# the user exactly which queries the cycle will run. Sentinel-delimited so it
|
|
421
|
+
# survives alongside the human/stderr log noise. On --dry-run we report what
|
|
422
|
+
# we drafted (nothing persisted yet); otherwise the live active bank.
|
|
423
|
+
if args.emit_json:
|
|
424
|
+
if args.dry_run:
|
|
425
|
+
queries = [{"query": q, "topic": t} for q, t in drafted]
|
|
426
|
+
else:
|
|
427
|
+
queries = _fetch_active_queries(project)
|
|
428
|
+
print("===QUERIES_JSON===")
|
|
429
|
+
print(json.dumps({"project": project, "count": len(queries),
|
|
430
|
+
"queries": queries}))
|
|
431
|
+
|
|
432
|
+
return 1 if (failed and not (inserted or updated)) else 0
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
if __name__ == "__main__":
|
|
436
|
+
try:
|
|
437
|
+
_rc = main()
|
|
438
|
+
except BrokenPipeError:
|
|
439
|
+
# The MCP setup hook (our parent) closes stdout once it has read the
|
|
440
|
+
# sentinel ===QUERIES_JSON=== block; the trailing summary prints then hit
|
|
441
|
+
# a dead pipe and raise BrokenPipeError. All persistence already happened
|
|
442
|
+
# earlier in main(), so this is BENIGN. Previously it propagated as an
|
|
443
|
+
# uncaught exception and Sentry logged it as a "seeding failed" event
|
|
444
|
+
# (Karol, 2026-06-22) — a false positive that buried the real signal.
|
|
445
|
+
# Redirect stdout to devnull so interpreter shutdown doesn't re-raise on
|
|
446
|
+
# the final flush, then exit clean.
|
|
447
|
+
try:
|
|
448
|
+
_devnull = os.open(os.devnull, os.O_WRONLY)
|
|
449
|
+
os.dup2(_devnull, sys.stdout.fileno())
|
|
450
|
+
except Exception:
|
|
451
|
+
pass
|
|
452
|
+
_rc = 0
|
|
453
|
+
raise SystemExit(_rc)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Seed project_search_topics from config.json (one-time-per-install bootstrap).
|
|
3
|
+
|
|
4
|
+
The DB is now the source of truth for the search-topic universe (see migration
|
|
5
|
+
2026-05-27-project-search-topics.sql and pick_search_topic.py). This script
|
|
6
|
+
mirrors the project_name -> search_topics[] block in ~/social-autoposter/config.json
|
|
7
|
+
into /api/v1/project-search-topics with source='seed', status='active'. The API
|
|
8
|
+
upserts on (install_id, project_name, topic), so re-running this script:
|
|
9
|
+
|
|
10
|
+
- Inserts rows added to config.json since the last seed.
|
|
11
|
+
- Leaves source unchanged for rows that already exist (server preserves
|
|
12
|
+
source on UPDATE; status is touched only because of upsert semantics, but
|
|
13
|
+
it lands on the same 'active' default we send).
|
|
14
|
+
- Never deletes rows (paused/excluded topics in the DB are protected from
|
|
15
|
+
config.json drift; explicit pause/exclude lives in the dashboard, not here).
|
|
16
|
+
|
|
17
|
+
CLI:
|
|
18
|
+
python3 scripts/seed_search_topics.py # seed every project
|
|
19
|
+
python3 scripts/seed_search_topics.py --project fazm
|
|
20
|
+
python3 scripts/seed_search_topics.py --dry-run # show counts, don't POST
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
30
|
+
|
|
31
|
+
from http_api import api_post # noqa: E402
|
|
32
|
+
|
|
33
|
+
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_projects(only_project: str | None = None):
|
|
37
|
+
with open(CONFIG_PATH) as f:
|
|
38
|
+
cfg = json.load(f)
|
|
39
|
+
out = []
|
|
40
|
+
for p in cfg.get("projects", []):
|
|
41
|
+
name = (p.get("name") or "").strip()
|
|
42
|
+
if not name:
|
|
43
|
+
continue
|
|
44
|
+
if only_project and name.lower() != only_project.lower():
|
|
45
|
+
continue
|
|
46
|
+
topics = []
|
|
47
|
+
seen = set()
|
|
48
|
+
for t in (p.get("search_topics") or []):
|
|
49
|
+
t = (t or "").strip()
|
|
50
|
+
if t and t not in seen:
|
|
51
|
+
seen.add(t)
|
|
52
|
+
topics.append(t)
|
|
53
|
+
out.append((name, topics))
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main():
|
|
58
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
59
|
+
ap.add_argument("--project", default=None,
|
|
60
|
+
help="Only seed this project name (default: all)")
|
|
61
|
+
ap.add_argument("--dry-run", action="store_true",
|
|
62
|
+
help="Print counts; do not POST")
|
|
63
|
+
args = ap.parse_args()
|
|
64
|
+
|
|
65
|
+
projects = _load_projects(args.project)
|
|
66
|
+
if not projects:
|
|
67
|
+
sys.stderr.write(
|
|
68
|
+
f"seed_search_topics: no projects matched "
|
|
69
|
+
f"(filter={args.project!r})\n"
|
|
70
|
+
)
|
|
71
|
+
sys.exit(2)
|
|
72
|
+
|
|
73
|
+
total_inserted = 0
|
|
74
|
+
total_updated = 0
|
|
75
|
+
total_failed = 0
|
|
76
|
+
total_planned = 0
|
|
77
|
+
|
|
78
|
+
for name, topics in projects:
|
|
79
|
+
total_planned += len(topics)
|
|
80
|
+
if args.dry_run:
|
|
81
|
+
print(f"[dry] {name}: {len(topics)} topics")
|
|
82
|
+
continue
|
|
83
|
+
inserted = 0
|
|
84
|
+
updated = 0
|
|
85
|
+
for topic in topics:
|
|
86
|
+
try:
|
|
87
|
+
resp = api_post(
|
|
88
|
+
"/api/v1/project-search-topics",
|
|
89
|
+
body={
|
|
90
|
+
"project": name,
|
|
91
|
+
"topic": topic,
|
|
92
|
+
"source": "seed",
|
|
93
|
+
"status": "active",
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
except SystemExit as e:
|
|
97
|
+
total_failed += 1
|
|
98
|
+
print(f"[FAIL] {name}: {topic!r}: {e}", file=sys.stderr)
|
|
99
|
+
continue
|
|
100
|
+
data = (resp or {}).get("data") or resp or {}
|
|
101
|
+
action = data.get("action") or ""
|
|
102
|
+
if action == "inserted":
|
|
103
|
+
inserted += 1
|
|
104
|
+
elif action == "updated":
|
|
105
|
+
updated += 1
|
|
106
|
+
total_inserted += inserted
|
|
107
|
+
total_updated += updated
|
|
108
|
+
print(
|
|
109
|
+
f"{name}: planned={len(topics)} inserted={inserted} "
|
|
110
|
+
f"updated={updated}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if args.dry_run:
|
|
114
|
+
print(f"[dry] total topics across {len(projects)} project(s): {total_planned}")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
print(
|
|
118
|
+
f"\ndone. projects={len(projects)} planned={total_planned} "
|
|
119
|
+
f"inserted={total_inserted} updated={total_updated} "
|
|
120
|
+
f"failed={total_failed}"
|
|
121
|
+
)
|
|
122
|
+
if total_failed:
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
main()
|