mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-09-11 22:51:05 +00:00
fix: restore X social monitoring
This commit is contained in:
parent
38277815ed
commit
3acc195e6e
10 changed files with 170 additions and 45 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string, { data: unknown; ts: number }>();
|
||||
const CACHE_TTL = 3 * 60_000; // 3 min
|
||||
|
||||
function cached<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
function cached<T>(key: string, fn: () => Promise<T>, ttl = CACHE_TTL): Promise<T> {
|
||||
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<string, string>,
|
||||
_body: string,
|
||||
_headers: IncomingHttpHeaders,
|
||||
headers: IncomingHttpHeaders,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<unknown> {
|
||||
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<CommunityPost[]> {
|
||||
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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -197,6 +197,14 @@ function renderPreferencesTab(container: HTMLElement): void {
|
|||
<textarea class="settings-textarea" data-pref="githubRepos" rows="4">${p.githubRepos.join('\n')}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Social</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">X search keywords (comma-separated)</label>
|
||||
<input class="settings-input" data-pref="socialKeywords" value="${escapeHtml(p.socialKeywords.join(', '))}" />
|
||||
<div class="settings-hint">Requires an Xquik API key. Leave blank to disable X search.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Feishu</div>
|
||||
<div class="settings-row">
|
||||
|
|
|
|||
|
|
@ -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<string, { label: string; color: string; icon: string
|
|||
hn: { label: 'HN', color: '#ff6600', icon: 'Y' },
|
||||
reddit: { label: 'Reddit', color: '#ff4500', icon: 'r/' },
|
||||
v2ex: { label: 'V2EX', color: '#333', icon: 'V' },
|
||||
x: { label: 'X', color: '#111', icon: 'X' },
|
||||
};
|
||||
|
||||
const TABS: { id: CommunitySource; label: string }[] = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'hn', label: 'Hacker News' },
|
||||
{ id: 'reddit', label: 'Reddit' },
|
||||
{ id: 'x', label: 'X' },
|
||||
];
|
||||
|
||||
export class SocialPanel extends Panel {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export type SecretKey =
|
|||
| 'OUTLOOK_REFRESH_TOKEN'
|
||||
| 'FEISHU_APP_ID'
|
||||
| 'FEISHU_APP_SECRET'
|
||||
| 'TWITTER_BEARER_TOKEN'
|
||||
| 'XQUIK_API_KEY'
|
||||
| 'OPENROUTER_API_KEY'
|
||||
| 'OPENROUTER_MODEL';
|
||||
|
||||
|
|
@ -43,12 +43,11 @@ export const SECRET_REGISTRY: SecretMeta[] = [
|
|||
// Feishu
|
||||
{ key: 'FEISHU_APP_ID', label: 'Feishu App ID', placeholder: 'cli_...', group: 'Feishu', required: true },
|
||||
{ key: 'FEISHU_APP_SECRET', label: 'Feishu App Secret', placeholder: '', group: 'Feishu', type: 'password' },
|
||||
// Twitter
|
||||
{ key: 'TWITTER_BEARER_TOKEN', label: 'Twitter Bearer Token', placeholder: 'AAAA...', group: 'Social', type: 'password' },
|
||||
// X search
|
||||
{ key: 'XQUIK_API_KEY', label: 'Xquik API Key', placeholder: 'xq_...', group: 'Social', type: 'password', hint: 'For opt-in X keyword search' },
|
||||
// AI
|
||||
{ key: 'OPENROUTER_API_KEY', label: 'OpenRouter API Key', placeholder: 'sk-or-...', group: 'AI', type: 'password', hint: 'For AI summaries' },
|
||||
{ key: 'OPENROUTER_MODEL', label: 'AI Model', placeholder: 'minimax/minimax-m2.5', group: 'AI', hint: 'OpenRouter model ID' },
|
||||
];
|
||||
|
||||
export const SECRET_GROUPS = [...new Set(SECRET_REGISTRY.map(s => s.group))];
|
||||
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
|
|
|
|||
|
|
@ -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<CommunityPost[]>({
|
|||
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<CommunityPost[]> {
|
||||
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[];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue