fix: restore X social monitoring

This commit is contained in:
kriptoburak 2026-08-22 16:44:21 +03:00
parent 38277815ed
commit 3acc195e6e
10 changed files with 170 additions and 45 deletions

View file

@ -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

View file

@ -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; }

View file

@ -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);

View file

@ -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
// ============================================================

View file

@ -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

View file

@ -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">

View file

@ -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 {

View file

@ -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))];

View file

@ -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'] },

View file

@ -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[];