diff --git a/examples/my-daily-monitor/README.md b/examples/my-daily-monitor/README.md index a124f6d..d4d6356 100644 --- a/examples/my-daily-monitor/README.md +++ b/examples/my-daily-monitor/README.md @@ -107,7 +107,7 @@ Open the dashboard and click **⚙ Settings** in the top-right corner. Add your | `OUTLOOK_REFRESH_TOKEN` | Email (Outlook) | Microsoft Graph OAuth flow | | `FEISHU_APP_ID` | Feishu Messages | Feishu Open Platform | | `FEISHU_APP_SECRET` | Feishu Messages | Feishu Open Platform | -| `TWITTER_BEARER_TOKEN` | Social Feed | Twitter Developer Portal | +| `XQUIK_API_KEY` | X keyword search (optional) | [Xquik authentication](https://docs.xquik.com/api-reference/authentication) | | `OPENROUTER_API_KEY` | AI Agent | [openrouter.ai](https://openrouter.ai/) | | `OPENROUTER_MODEL` | AI Model (optional) | Default: `minimax/minimax-m2.5` | @@ -124,6 +124,7 @@ Open the dashboard and click **⚙ Settings** in the top-right corner. Add your | News Alert Keywords | Keyword highlights across all news sources | | GitHub Repos | Repos to monitor for CI/CD (`owner/repo`, one per line) | | Feishu Chat IDs | Feishu group chats to stream messages from | +| X Search Keywords | Terms to monitor through the [Xquik search API](https://docs.xquik.com/api-reference/x/search-tweets) | | AI Summaries | Toggle AI-generated daily briefing on/off | #### Customization diff --git a/examples/my-daily-monitor/server/index.ts b/examples/my-daily-monitor/server/index.ts index 9f6c6fd..4ae9df9 100644 --- a/examples/my-daily-monitor/server/index.ts +++ b/examples/my-daily-monitor/server/index.ts @@ -15,7 +15,7 @@ const PORT = Number(process.env.API_PORT || 3001); const server = http.createServer(async (req, res) => { // CORS res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, X-Finnhub-Key, X-Github-Token, X-Feishu-App-Id, X-Feishu-App-Secret, X-Twitter-Token, X-Gmail-Token, X-OpenRouter-Key'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, X-Finnhub-Key, X-Github-Token, X-Feishu-App-Id, X-Feishu-App-Secret, X-Xquik-Key, X-Gmail-Token, X-OpenRouter-Key'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } diff --git a/examples/my-daily-monitor/server/routes/index.test.ts b/examples/my-daily-monitor/server/routes/index.test.ts index 398d6e1..e5cc8d3 100644 --- a/examples/my-daily-monitor/server/routes/index.test.ts +++ b/examples/my-daily-monitor/server/routes/index.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { apiRoutes } from './index'; +import { handleSocialRequest } from './social'; const requiredRoutes = [ '/api/stocks', @@ -19,3 +20,55 @@ for (const route of requiredRoutes) { } assert.deepEqual(Object.keys(apiRoutes), requiredRoutes); + +const requests: { url: string; apiKey: string }[] = []; +const fakeFetch: typeof fetch = async (input, init) => { + const requestHeaders = new Headers(init?.headers); + requests.push({ url: String(input), apiKey: requestHeaders.get('x-api-key') || '' }); + return new Response(JSON.stringify({ + tweets: [{ + id: '1893456789012345678', + text: 'A useful OpenSpace update', + createdAt: '2026-08-22T10:00:00.000Z', + likeCount: 12, + retweetCount: 3, + replyCount: 2, + quoteCount: 1, + url: 'https://example.com/untrusted', + author: { username: 'openspace', name: 'OpenSpace' }, + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); +}; + +const xResult = await handleSocialRequest( + { source: 'x', q: 'agent skills' }, + '', + { 'x-xquik-key': 'xq_test' }, + fakeFetch, +) as { posts: unknown[]; source: string; configured: boolean }; + +assert.equal(requests.length, 1); +const requestUrl = new URL(requests[0].url); +assert.equal(requestUrl.origin + requestUrl.pathname, 'https://xquik.com/api/v1/x/tweets/search'); +assert.equal(requestUrl.searchParams.get('q'), 'agent skills'); +assert.equal(requestUrl.searchParams.get('queryType'), 'Latest'); +assert.equal(requestUrl.searchParams.get('limit'), '10'); +assert.equal(requests[0].apiKey, 'xq_test'); +assert.deepEqual(xResult, { + posts: [{ + id: 'x-1893456789012345678', + title: 'A useful OpenSpace update', + url: 'https://x.com/openspace/status/1893456789012345678', + score: 16, + comments: 2, + author: '@openspace', + platform: 'x', + timestamp: '2026-08-22T10:00:00.000Z', + }], + source: 'x', + configured: true, +}); + +const unconfiguredResult = await handleSocialRequest({ source: 'x', q: 'agent skills' }, '', {}, fakeFetch); +assert.deepEqual(unconfiguredResult, { posts: [], source: 'x', configured: false }); +assert.equal(requests.length, 1); diff --git a/examples/my-daily-monitor/server/routes/social.ts b/examples/my-daily-monitor/server/routes/social.ts index b622de5..f21feb8 100644 --- a/examples/my-daily-monitor/server/routes/social.ts +++ b/examples/my-daily-monitor/server/routes/social.ts @@ -1,10 +1,9 @@ -/** - * Tech Community API — aggregates free APIs from HN, Reddit, V2EX. - * All endpoints are free and require NO API keys. - */ +/** Tech Community API — aggregates HN, Reddit, and optional X search results. */ import type { IncomingHttpHeaders } from 'node:http'; const FETCH_TIMEOUT = 8000; +const XQUIK_CACHE_TTL = 15 * 60_000; +const XQUIK_SEARCH_LIMIT = 10; interface CommunityPost { id: string; @@ -17,38 +16,63 @@ interface CommunityPost { timestamp: string; } +interface XquikTweet { + id?: string; + text?: string; + createdAt?: string; + likeCount?: number; + retweetCount?: number; + replyCount?: number; + quoteCount?: number; + author?: { + username?: string; + name?: string; + }; +} + // ---- Cache ---- const cache = new Map(); const CACHE_TTL = 3 * 60_000; // 3 min -function cached(key: string, fn: () => Promise): Promise { +function cached(key: string, fn: () => Promise, ttl = CACHE_TTL): Promise { const entry = cache.get(key); - if (entry && Date.now() - entry.ts < CACHE_TTL) return Promise.resolve(entry.data as T); + if (entry && Date.now() - entry.ts < ttl) return Promise.resolve(entry.data as T); return fn().then(data => { cache.set(key, { data, ts: Date.now() }); return data; }); } export async function handleSocialRequest( query: Record, _body: string, - _headers: IncomingHttpHeaders, + headers: IncomingHttpHeaders, + fetchImpl: typeof fetch = fetch, ): Promise { const source = query.source || 'all'; + const headerKey = headers['x-xquik-key']; + const apiKey = (Array.isArray(headerKey) ? headerKey[0] : headerKey) || process.env.XQUIK_API_KEY || ''; + const searchQuery = (query.q || '').trim().slice(0, 512); try { if (source === 'hn') return { posts: await fetchHN(), source: 'hn' }; if (source === 'reddit') return { posts: await fetchReddit(query.sub || 'programming'), source: 'reddit' }; + if (source === 'x') { + if (!apiKey || !searchQuery) return { posts: [], source: 'x', configured: false }; + return { posts: await fetchXquik(searchQuery, apiKey, fetchImpl), source: 'x', configured: true }; + } // V2EX disabled — content quality issues // if (source === 'v2ex') return { posts: await fetchV2EX(), source: 'v2ex' }; - // Fetch HN + Reddit in parallel - const [hn, reddit] = await Promise.allSettled([ + const requests = [ fetchHN(), fetchReddit(query.sub || 'programming'), - ]); + ]; + if (apiKey && searchQuery) requests.push(fetchXquik(searchQuery, apiKey, fetchImpl)); + + const results = await Promise.allSettled(requests); const posts: CommunityPost[] = []; - if (hn.status === 'fulfilled') posts.push(...hn.value); - if (reddit.status === 'fulfilled') posts.push(...reddit.value); + for (const result of results) { + if (result.status === 'fulfilled') posts.push(...result.value); + } // Sort by score descending posts.sort((a, b) => b.score - a.score); @@ -59,6 +83,45 @@ export async function handleSocialRequest( } } +// ============================================================ +// X — Xquik search API (opt-in, API key required) +// ============================================================ +async function fetchXquik(query: string, apiKey: string, fetchImpl: typeof fetch): Promise { + return cached(`xquik-${query}`, async () => { + const params = new URLSearchParams({ q: query, queryType: 'Latest', limit: String(XQUIK_SEARCH_LIMIT) }); + const resp = await fetchImpl(`https://xquik.com/api/v1/x/tweets/search?${params}`, { + headers: { 'x-api-key': apiKey }, + signal: AbortSignal.timeout(FETCH_TIMEOUT), + }); + if (!resp.ok) throw new Error(`Xquik ${resp.status}`); + const data = await resp.json() as { tweets?: XquikTweet[] } | null; + const tweets = Array.isArray(data?.tweets) ? data.tweets : []; + + return tweets + .filter((tweet): tweet is XquikTweet & { id: string; text: string } => + typeof tweet.id === 'string' && typeof tweet.text === 'string' && tweet.text.length > 0) + .map(tweet => { + const username = tweet.author?.username || ''; + const profile = /^[A-Za-z0-9_]{1,15}$/.test(username) ? username : 'i/web'; + const url = `https://x.com/${profile}/status/${encodeURIComponent(tweet.id)}`; + const timestamp = tweet.createdAt && Number.isFinite(Date.parse(tweet.createdAt)) + ? tweet.createdAt + : new Date(0).toISOString(); + + return { + id: `x-${tweet.id}`, + title: tweet.text, + url, + score: (tweet.likeCount || 0) + (tweet.retweetCount || 0) + (tweet.quoteCount || 0), + comments: tweet.replyCount || 0, + author: username ? `@${username}` : (tweet.author?.name || ''), + platform: 'x', + timestamp, + }; + }); + }, XQUIK_CACHE_TTL); +} + // ============================================================ // Hacker News — https://github.com/HackerNews/API // ============================================================ diff --git a/examples/my-daily-monitor/skills/personal-monitor-domain/SKILL.md b/examples/my-daily-monitor/skills/personal-monitor-domain/SKILL.md index 0e4e415..2e3dc94 100644 --- a/examples/my-daily-monitor/skills/personal-monitor-domain/SKILL.md +++ b/examples/my-daily-monitor/skills/personal-monitor-domain/SKILL.md @@ -25,7 +25,7 @@ This skill defines WHAT the my-daily-monitor dashboard should do — the panels, | 5 | News Feed | GNews / HackerNews | P0 — Core | Implemented | | 6 | Code Status (CI/CD) | GitHub Actions API | P1 — Important | Implemented | | 7 | Office Documents | Microsoft Graph API | P1 — Important | Implemented | -| 8 | Social Feed (Twitter) | Twitter API v2 | P1 — Important | Implemented | +| 8 | Social Feed (X) | Xquik search API | P1 — Important | Implemented | | 9 | Daily Finance | Manual / Spreadsheet | P1 — Important | Implemented | | 10 | Weather | OpenWeatherMap / wttr.in | P1 — Important | Implemented | | 11 | World Clock | Built-in (no API) | P2 — Nice to Have | Implemented | @@ -221,12 +221,14 @@ interface OfficeDocument { ### 8. Social Feed -**Purpose**: Twitter timeline + Xiaohongshu content +**Purpose**: Community news + X keyword monitoring -**Twitter API v2**: -- Auth: Bearer Token -- Endpoint: `GET /api/social?action=search&q=...` or `action=list&listId=...` -- Server-side: Forward to Twitter search/list endpoint +**X search through Xquik**: +- Auth: Xquik API key in the `x-api-key` request header +- Endpoint: `GET https://xquik.com/api/v1/x/tweets/search` +- Query: User-defined social keywords with `queryType=Latest` +- Server-side: Forward the request through `/api/social`; never put the API key in a URL +- Cost control: Fetch at most 10 posts and cache results for 15 minutes **Xiaohongshu**: - No official API; requires web scraping or reverse-engineered endpoints @@ -236,15 +238,13 @@ interface OfficeDocument { ```typescript interface SocialPost { id: string; - platform: 'twitter' | 'xiaohongshu'; + platform: 'hn' | 'reddit' | 'x'; author: string; - username?: string; - content: string; + title: string; + url: string; timestamp: string; - likes?: number; - retweets?: number; - comments?: number; - url?: string; + score: number; + comments: number; } ``` @@ -320,7 +320,7 @@ All API keys are stored in browser localStorage via a Settings modal: | `FEISHU_APP_ID` | Feishu | Feishu Open Platform | | `FEISHU_APP_SECRET` | Feishu | Feishu Open Platform | | `GITHUB_TOKEN` | Code Status | GitHub → Settings → PAT | -| `TWITTER_BEARER_TOKEN` | Social Feed | Twitter Developer Portal | +| `XQUIK_API_KEY` | X keyword search | https://docs.xquik.com/api-reference/authentication | | `GROQ_API_KEY` | AI Insights | https://console.groq.com/ | Keys are passed to the server via request headers (never in query params), and the server-side proxy uses them to call external APIs. @@ -343,4 +343,3 @@ This dashboard handles personal data. Key considerations: **Phase 2** (Complete): Code Status + Office + Social + Finance + Weather **Phase 3** (Enhanced): Map + AI Insights + World Clock + Xiaohongshu **Phase 4** (Polish): DevOps + Live News + Quick Links + My Monitors + Command Palette - diff --git a/examples/my-daily-monitor/src/components/SettingsModal.ts b/examples/my-daily-monitor/src/components/SettingsModal.ts index 8574136..7a43c9b 100644 --- a/examples/my-daily-monitor/src/components/SettingsModal.ts +++ b/examples/my-daily-monitor/src/components/SettingsModal.ts @@ -197,6 +197,14 @@ function renderPreferencesTab(container: HTMLElement): void { +
+
Social
+
+ + +
Requires an Xquik API key. Leave blank to disable X search.
+
+
Feishu
diff --git a/examples/my-daily-monitor/src/components/SocialPanel.ts b/examples/my-daily-monitor/src/components/SocialPanel.ts index bf3df19..4155996 100644 --- a/examples/my-daily-monitor/src/components/SocialPanel.ts +++ b/examples/my-daily-monitor/src/components/SocialPanel.ts @@ -1,7 +1,4 @@ -/** - * Tech Community Panel — HN / Reddit / V2EX aggregator. - * All free APIs, no keys needed. - */ +/** Tech Community Panel — HN, Reddit, and optional X search results. */ import { Panel } from './Panel'; import { fetchCommunityPosts, type CommunityPost, type CommunitySource } from '@/services/social'; import { formatTime, escapeHtml } from '@/utils'; @@ -10,12 +7,14 @@ const PLATFORM_META: Record s.group))]; - diff --git a/examples/my-daily-monitor/src/main.ts b/examples/my-daily-monitor/src/main.ts index 17bdf02..549bdf4 100644 --- a/examples/my-daily-monitor/src/main.ts +++ b/examples/my-daily-monitor/src/main.ts @@ -273,7 +273,7 @@ registerCommands([ { label: 'Schedule', description: 'Jump to schedule panel', action: () => scrollToPanel('schedule'), keywords: ['calendar', 'events'] }, { label: 'Feishu', description: 'Jump to Feishu panel', action: () => scrollToPanel('feishu'), keywords: ['lark', 'chat'] }, { label: 'Code Status', description: 'Jump to CI/CD panel', action: () => scrollToPanel('code-status'), keywords: ['github', 'ci'] }, - { label: 'Social', description: 'Jump to community feed', action: () => scrollToPanel('social'), keywords: ['hn', 'reddit'] }, + { label: 'Social', description: 'Jump to community feed', action: () => scrollToPanel('social'), keywords: ['hn', 'reddit', 'x'] }, { label: 'Finance', description: 'Jump to daily finance', action: () => scrollToPanel('finance'), keywords: ['expenses'] }, { label: 'Map', description: 'Jump to global map', action: () => scrollToPanel('map'), keywords: ['globe', 'world'] }, { label: 'Weather', description: 'Jump to weather panel', action: () => scrollToPanel('weather'), keywords: ['forecast', 'temperature'] }, diff --git a/examples/my-daily-monitor/src/services/social.ts b/examples/my-daily-monitor/src/services/social.ts index aaa4ed1..5511168 100644 --- a/examples/my-daily-monitor/src/services/social.ts +++ b/examples/my-daily-monitor/src/services/social.ts @@ -1,8 +1,6 @@ -/** - * Tech Community service — HN, Reddit, V2EX. - * All free, no API keys needed. - */ +/** Tech Community service — HN, Reddit, and optional X search results. */ import { createCircuitBreaker } from '@/utils/circuit-breaker'; +import { getPreferences, getSecret } from '@/services/settings-store'; export interface CommunityPost { id: string; @@ -11,7 +9,7 @@ export interface CommunityPost { score: number; comments: number; author: string; - platform: 'hn' | 'reddit' | 'v2ex'; + platform: 'hn' | 'reddit' | 'v2ex' | 'x'; timestamp: string; } @@ -20,11 +18,16 @@ const communityBreaker = createCircuitBreaker({ cacheTtlMs: 3 * 60_000, }); -export type CommunitySource = 'all' | 'hn' | 'reddit' | 'v2ex'; +export type CommunitySource = 'all' | 'hn' | 'reddit' | 'v2ex' | 'x'; export async function fetchCommunityPosts(source: CommunitySource = 'all'): Promise { return communityBreaker.execute(async () => { - const resp = await fetch(`/api/social?source=${source}`); + const apiKey = getSecret('XQUIK_API_KEY'); + const searchQuery = getPreferences().socialKeywords.join(' OR '); + const params = new URLSearchParams({ source }); + if (searchQuery) params.set('q', searchQuery); + const headers = apiKey ? { 'X-Xquik-Key': apiKey } : undefined; + const resp = await fetch(`/api/social?${params}`, { headers }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); return (data.posts || []) as CommunityPost[];