Ctrl K
ComunidadGuías…

scripts/recommend_from_profile.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/bin/bash
set -euo pipefail

WORKSPACE_DIR="${WORKSPACE_DIR:-${OPENCLAW_WORKSPACE:-$HOME/.openclaw/workspace}}"
SKILL_DIR="${SKILL_DIR:-$WORKSPACE_DIR/skills/x-knowledge-base}"
BOOKMARKS_DIR="${BOOKMARKS_DIR:-$WORKSPACE_DIR/memory/bookmarks}"
SECRETS_FILE="${SECRETS_FILE:-$WORKSPACE_DIR/.secrets/x-knowledge-base.env}"
TOPIC_CONFIG="${TOPIC_CONFIG:-$SKILL_DIR/config/recommendation-topics.json}"
OUT_DIR="${OUT_DIR:-$WORKSPACE_DIR/memory/x-knowledge-base/recommendations}"

mkdir -p "$OUT_DIR"

if [[ -f "$SECRETS_FILE" ]]; then
  source "$SECRETS_FILE"
fi

# 兼容現有 bird 憑證命名
TWITTER_AUTH_TOKEN="${TWITTER_AUTH_TOKEN:-${BIRD_AUTH_TOKEN:-}}"
TWITTER_CT0="${TWITTER_CT0:-${BIRD_CT0:-}}"
export TWITTER_AUTH_TOKEN TWITTER_CT0

export PATH="$HOME/.local/bin:$PATH"
if ! command -v twitter >/dev/null 2>&1; then
  echo "❌ 找不到 twitter-cli(command: twitter)"
  exit 1
fi

if [[ -z "${TWITTER_AUTH_TOKEN:-}" || -z "${TWITTER_CT0:-}" ]]; then
  echo "❌ 缺少 TWITTER_AUTH_TOKEN / TWITTER_CT0(或 BIRD_AUTH_TOKEN / BIRD_CT0)"
  exit 1
fi

TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT

SEARCH_INDEX="$BOOKMARKS_DIR/search_index.json"
[[ -f "$SEARCH_INDEX" ]] || echo '{"items":[]}' > "$SEARCH_INDEX"

python3 - <<'PY' "$TOPIC_CONFIG" "$SEARCH_INDEX" "$TMP_DIR/profile.json"
import json, re, sys
cfg_path, idx_path, out_path = sys.argv[1:4]
cfg = json.load(open(cfg_path, 'r', encoding='utf-8'))
try:
    idx = json.load(open(idx_path, 'r', encoding='utf-8'))
except Exception:
    idx = {"items": []}
items = idx if isinstance(idx, list) else idx.get('items', [])

def flatten(item):
    if isinstance(item, str):
        return item
    if isinstance(item, list):
        return ' '.join(flatten(x) for x in item)
    if isinstance(item, dict):
        return ' '.join(flatten(v) for v in item.values())
    return ''

text_blob = '\n'.join(flatten(x).lower() for x in items[:2000])
profile = {}
for t in cfg.get('topics', []):
    k = t['key']
    score = 0
    for kw in t.get('keywords', []):
        if not kw:
            continue
        score += len(re.findall(re.escape(kw.lower()), text_blob))
    profile[k] = score

# 平滑,避免 0 分全部沒排序
for k in list(profile.keys()):
    profile[k] = max(profile[k], 1)

json.dump({"topicScore": profile}, open(out_path, 'w', encoding='utf-8'), ensure_ascii=False)
PY

python3 - <<'PY' "$TOPIC_CONFIG" "$TMP_DIR/plan.json"
import json, sys
cfg = json.load(open(sys.argv[1], 'r', encoding='utf-8'))
feeds = cfg.get('feeds', ['following'])
max_per_feed = int(cfg.get('maxPerFeed', 40))
json.dump({"feeds": feeds, "max": max_per_feed}, open(sys.argv[2], 'w', encoding='utf-8'))
PY

FEEDS=$(python3 - <<'PY' "$TMP_DIR/plan.json"
import json,sys
p=json.load(open(sys.argv[1]))
print('\n'.join(p['feeds']))
PY
)
MAXN=$(python3 - <<'PY' "$TMP_DIR/plan.json"
import json,sys
p=json.load(open(sys.argv[1]))
print(p['max'])
PY
)

for feed in $FEEDS; do
  if [[ "$feed" == "following" ]]; then
    twitter feed -t following --max "$MAXN" --json > "$TMP_DIR/feed-following.json" || echo '{"data":[]}' > "$TMP_DIR/feed-following.json"
  else
    twitter feed --max "$MAXN" --json > "$TMP_DIR/feed-foryou.json" || echo '{"data":[]}' > "$TMP_DIR/feed-foryou.json"
  fi
done

twitter bookmarks --max "$MAXN" --json > "$TMP_DIR/bookmarks.json" || echo '{"data":[]}' > "$TMP_DIR/bookmarks.json"

python3 - <<'PY' "$TOPIC_CONFIG" "$TMP_DIR/profile.json" "$TMP_DIR/feed-following.json" "$TMP_DIR/feed-foryou.json" "$TMP_DIR/bookmarks.json" "$OUT_DIR"
import json, re, sys
from datetime import datetime, timezone

cfg_path, profile_path, following_path, foryou_path, bookmarks_path, out_dir = sys.argv[1:7]
cfg = json.load(open(cfg_path, 'r', encoding='utf-8'))
profile = json.load(open(profile_path, 'r', encoding='utf-8')).get('topicScore', {})

def load_items(path, source):
    try:
        d = json.load(open(path, 'r', encoding='utf-8'))
    except Exception:
        return []
    arr = d if isinstance(d, list) else d.get('data', d.get('items', []))
    out = []
    for it in arr or []:
        if not isinstance(it, dict):
            continue
        x = dict(it)
        x['_source'] = source
        out.append(x)
    return out

feed_items = load_items(following_path, 'following') + load_items(foryou_path, 'for-you')
bookmark_items = load_items(bookmarks_path, 'bookmarks')

# 取最近書籤當「你最近偏好」依據
recent_bookmark_text = ' '.join((it.get('text') or '') for it in bookmark_items[:50]).lower()

topic_map = {t['key']: t for t in cfg.get('topics', [])}

def score_item(it):
    text = ((it.get('text') or '') + ' ' + ' '.join(it.get('urls', []) or [])).lower()
    topic_hits = {}
    score = 0.0
    for k, t in topic_map.items():
        hits = 0
        for kw in t.get('keywords', []):
            kw_l = kw.lower()
            if kw_l in text:
                hits += 1
        if hits:
            weighted = hits * float(profile.get(k, 1))
            topic_hits[k] = weighted
            score += weighted

    # 與最近書籤關聯(簡單 keyword overlap)
    overlap_bonus = 0
    for w in set(re.findall(r'[a-z][a-z0-9\-]{3,}', text)):
        if w in recent_bookmark_text:
            overlap_bonus += 0.08
    score += min(overlap_bonus, 2.5)

    # engagement bonus
    m = it.get('metrics') or {}
    likes = float(m.get('likes') or 0)
    rts = float(m.get('retweets') or 0)
    replies = float(m.get('replies') or 0)
    score += (likes * 0.002 + rts * 0.008 + replies * 0.006)

    return score, topic_hits

ranked = []
for it in feed_items:
    s, hits = score_item(it)
    if s <= 0:
        continue
    it['_score'] = round(s, 4)
    it['_topic_hits'] = hits
    ranked.append(it)

ranked.sort(key=lambda x: x.get('_score', 0), reverse=True)
topk = int(cfg.get('topK', 12))
top = ranked[:topk]

now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')

summary = {
    'generatedAt': now,
    'profile': profile,
    'recommendations': top,
    'candidateCount': len(feed_items)
}

with open(f"{out_dir}/latest.json", 'w', encoding='utf-8') as f:
    json.dump(summary, f, ensure_ascii=False, indent=2)

lines = []
lines.append(f"# X 推薦摘要({now})")
lines.append("")
lines.append("## 你的興趣權重(由累積書籤推估)")
for k, v in sorted(profile.items(), key=lambda kv: kv[1], reverse=True):
    label = topic_map.get(k, {}).get('label', k)
    lines.append(f"- {label}: {v}")

lines.append("")
lines.append(f"## 推薦內容 Top {len(top)}(候選 {len(feed_items)})")
for i, it in enumerate(top, 1):
    author = (it.get('author') or {}).get('screenName', 'unknown')
    tid = it.get('id', '')
    url = f"https://x.com/{author}/status/{tid}" if tid else ""
    text = (it.get('text') or '').replace('\n', ' ').strip()
    text = text[:120] + ('…' if len(text) > 120 else '')
    topics = sorted((it.get('_topic_hits') or {}).items(), key=lambda kv: kv[1], reverse=True)
    topic_txt = ', '.join(topic_map.get(k, {}).get('label', k) for k, _ in topics[:2])
    lines.append(f"{i}. [{it.get('_source')}] @{author}|{text}")
    lines.append(f"   - score: {it.get('_score')} | topics: {topic_txt or 'general'}")
    if url:
      lines.append(f"   - {url}")

with open(f"{out_dir}/latest.md", 'w', encoding='utf-8') as f:
    f.write('\n'.join(lines) + '\n')

print(f"✅ 已產生推薦:{out_dir}/latest.md")
PY

echo "📌 完成:$OUT_DIR/latest.md"