chore: import upstream snapshot with attribution
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'stackoverflow',
|
||||
name: 'bounties',
|
||||
access: 'read',
|
||||
description: 'Active bounties on Stack Overflow',
|
||||
domain: 'stackoverflow.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max number of results' },
|
||||
],
|
||||
columns: ['rank', 'id', 'bounty', 'title', 'score', 'answers', 'views', 'is_answered', 'tags', 'author', 'creation_date', 'url'],
|
||||
pipeline: [
|
||||
{ fetch: {
|
||||
url: 'https://api.stackexchange.com/2.3/questions/featured?order=desc&sort=activity&site=stackoverflow&pagesize=${{ args.limit }}',
|
||||
} },
|
||||
{ select: 'items' },
|
||||
{ map: {
|
||||
rank: '${{ index + 1 }}',
|
||||
id: '${{ item.question_id }}',
|
||||
bounty: '${{ item.bounty_amount }}',
|
||||
title: '${{ item.title }}',
|
||||
score: '${{ item.score }}',
|
||||
answers: '${{ item.answer_count }}',
|
||||
views: '${{ item.view_count }}',
|
||||
is_answered: '${{ item.is_answered }}',
|
||||
tags: `\${{ item.tags | join(', ') }}`,
|
||||
author: '${{ item.owner.display_name }}',
|
||||
creation_date: '${{ item.creation_date }}',
|
||||
url: '${{ item.link }}',
|
||||
} },
|
||||
{ limit: '${{ args.limit }}' },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'stackoverflow',
|
||||
name: 'hot',
|
||||
access: 'read',
|
||||
description: 'Hot Stack Overflow questions',
|
||||
domain: 'stackoverflow.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max number of results' },
|
||||
],
|
||||
columns: ['rank', 'id', 'title', 'score', 'answers', 'views', 'is_answered', 'tags', 'author', 'creation_date', 'url'],
|
||||
pipeline: [
|
||||
{ fetch: { url: 'https://api.stackexchange.com/2.3/questions?order=desc&sort=hot&site=stackoverflow&pagesize=${{ args.limit }}' } },
|
||||
{ select: 'items' },
|
||||
{ map: {
|
||||
rank: '${{ index + 1 }}',
|
||||
id: '${{ item.question_id }}',
|
||||
title: '${{ item.title }}',
|
||||
score: '${{ item.score }}',
|
||||
answers: '${{ item.answer_count }}',
|
||||
views: '${{ item.view_count }}',
|
||||
is_answered: '${{ item.is_answered }}',
|
||||
tags: `\${{ item.tags | join(', ') }}`,
|
||||
author: '${{ item.owner.display_name }}',
|
||||
creation_date: '${{ item.creation_date }}',
|
||||
url: '${{ item.link }}',
|
||||
} },
|
||||
{ limit: '${{ args.limit }}' },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Stack Overflow question reader.
|
||||
*
|
||||
* Hits the public Stack Exchange API:
|
||||
* GET /questions/{id}?site=stackoverflow&filter=withbody
|
||||
* GET /questions/{id}/answers?site=stackoverflow&filter=withbody
|
||||
* GET /questions/{id}/comments?site=stackoverflow&filter=withbody
|
||||
* GET /answers/{a1;a2;...}/comments?site=stackoverflow&filter=withbody
|
||||
*
|
||||
* Three calls are needed because comments under answers are not bundled in
|
||||
* the answers payload. We batch all answer-comment fetches into a single
|
||||
* semicolon-joined call. SO has its own quota (300/day for unauthenticated
|
||||
* IP), but a `read` consumes at most 4 quota units, or 5 when the accepted
|
||||
* answer is missing from the requested answer page and must be fetched by id.
|
||||
*
|
||||
* Output rows mirror `hackernews read` and `lobsters read`:
|
||||
* - first row is the question itself (`type=POST`)
|
||||
* - one row per top-level question comment (`type=Q-COMMENT`)
|
||||
* - per answer: an `ANSWER` row plus its `A-COMMENT` rows indented under it
|
||||
* - the accepted answer (if any) is surfaced first and tagged `accepted=true`
|
||||
*/
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
const SE_API_BASE = 'https://api.stackexchange.com/2.3';
|
||||
const SE_SITE = 'stackoverflow';
|
||||
const SE_MAX_PAGE_SIZE = 100;
|
||||
|
||||
async function fetchJson(url, label) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url);
|
||||
} catch (e) {
|
||||
const detail = e instanceof Error ? e.message : String(e);
|
||||
throw new CommandExecutionError(
|
||||
`Network failure fetching ${label}: ${detail}`,
|
||||
'Check connectivity to api.stackexchange.com',
|
||||
);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
throw new EmptyResultError(label, `${label} not found`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new CommandExecutionError(
|
||||
`Stack Exchange API HTTP ${res.status} for ${label}`,
|
||||
'Check the question id and quota (300/day per IP)',
|
||||
);
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch (e) {
|
||||
const detail = e instanceof Error ? e.message : String(e);
|
||||
throw new CommandExecutionError(
|
||||
`Malformed JSON from Stack Exchange API for ${label}: ${detail}`,
|
||||
'The API returned a non-JSON body — likely a transient outage',
|
||||
);
|
||||
}
|
||||
if (json && json.error_id) {
|
||||
throw new CommandExecutionError(
|
||||
`Stack Exchange API error ${json.error_id} (${json.error_name}) for ${label}: ${json.error_message || ''}`,
|
||||
'Common causes: invalid filter, throttled, or quota exhausted',
|
||||
);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI args may arrive as strings (`--limit 5` → `'5'`) when not coerced by the
|
||||
* arg type system. Coerce-then-validate so `Number.isInteger` actually catches
|
||||
* the bad cases, and reject NaN explicitly.
|
||||
*/
|
||||
function coerceInt(value) {
|
||||
if (value === undefined || value === null || value === '') return NaN;
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) && Number.isInteger(n) ? n : NaN;
|
||||
}
|
||||
|
||||
function requireMinInt(value, min, label) {
|
||||
const n = coerceInt(value);
|
||||
if (!Number.isInteger(n) || n < min) {
|
||||
throw new ArgumentError(`${label} must be an integer >= ${min}, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function requireBoundedInt(value, min, max, label) {
|
||||
const n = coerceInt(value);
|
||||
if (!Number.isInteger(n) || n < min || n > max) {
|
||||
throw new ArgumentError(`${label} must be an integer between ${min} and ${max}, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function byAcceptedThenScoreDesc(question, answers) {
|
||||
const acceptedAnswerId = question.accepted_answer_id;
|
||||
return answers
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const aAccepted = a.is_accepted || (acceptedAnswerId && a.answer_id === acceptedAnswerId);
|
||||
const bAccepted = b.is_accepted || (acceptedAnswerId && b.answer_id === acceptedAnswerId);
|
||||
if (aAccepted !== bAccepted) return aAccepted ? -1 : 1;
|
||||
return (b.score ?? 0) - (a.score ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchMissingAcceptedAnswer(question, answers, label) {
|
||||
const acceptedAnswerId = question.accepted_answer_id;
|
||||
if (!acceptedAnswerId || answers.some((answer) => answer.answer_id === acceptedAnswerId)) {
|
||||
return answers;
|
||||
}
|
||||
const acceptedData = await fetchJson(
|
||||
`${SE_API_BASE}/answers/${acceptedAnswerId}?site=${SE_SITE}&filter=withbody`,
|
||||
`${label}/accepted-answer`,
|
||||
);
|
||||
const accepted = (acceptedData.items || [])[0];
|
||||
return accepted ? answers.concat(accepted) : answers;
|
||||
}
|
||||
|
||||
async function fetchAnswerCommentsByAnswerId(answers, commentsLimit, label) {
|
||||
const answerCommentsByAnswerId = new Map();
|
||||
if (answers.length === 0) return answerCommentsByAnswerId;
|
||||
|
||||
const ids = answers.map((a) => a.answer_id).join(';');
|
||||
const pageSize = Math.min(SE_MAX_PAGE_SIZE, answers.length * commentsLimit);
|
||||
const ansCommentsData = await fetchJson(
|
||||
`${SE_API_BASE}/answers/${ids}/comments?site=${SE_SITE}&filter=withbody&order=asc&sort=creation&pagesize=${pageSize}`,
|
||||
`${label}/answer-comments`,
|
||||
);
|
||||
for (const c of ansCommentsData.items || []) {
|
||||
if (!c.post_id) continue;
|
||||
if (!answerCommentsByAnswerId.has(c.post_id)) {
|
||||
answerCommentsByAnswerId.set(c.post_id, []);
|
||||
}
|
||||
answerCommentsByAnswerId.get(c.post_id).push(c);
|
||||
}
|
||||
|
||||
if (ansCommentsData.has_more) {
|
||||
const missingForSelectedAnswer = answers.some((answer) => {
|
||||
const comments = answerCommentsByAnswerId.get(answer.answer_id) || [];
|
||||
return comments.length < commentsLimit;
|
||||
});
|
||||
if (missingForSelectedAnswer) {
|
||||
throw new CommandExecutionError(
|
||||
`Stack Exchange answer comments for ${label} exceed one API page`,
|
||||
'Lower --answers-limit or --comments-limit; refusing to return a partial answer-comment set.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return answerCommentsByAnswerId;
|
||||
}
|
||||
|
||||
const NAMED_ENTITIES = {
|
||||
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
|
||||
hellip: '…', mdash: '—', ndash: '–', laquo: '«', raquo: '»',
|
||||
copy: '©', reg: '®', trade: '™', euro: '€', pound: '£', yen: '¥',
|
||||
rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“',
|
||||
};
|
||||
|
||||
/** Decode named/numeric HTML entities. Used on both body HTML and display names. */
|
||||
function decodeEntities(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
|
||||
const code = parseInt(hex, 16);
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : '';
|
||||
})
|
||||
.replace(/&#(\d+);/g, (_, dec) => {
|
||||
const code = parseInt(dec, 10);
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : '';
|
||||
})
|
||||
.replace(/&([a-zA-Z]+);/g, (match, name) => NAMED_ENTITIES[name] ?? match);
|
||||
}
|
||||
|
||||
/** SO renders bodies as HTML — convert to plain text similar to HN/lobsters. */
|
||||
function htmlToText(html) {
|
||||
if (!html) return '';
|
||||
const stripped = String(html)
|
||||
.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, '\n$1\n')
|
||||
.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`')
|
||||
.replace(/<p[^>]*>/gi, '\n\n')
|
||||
.replace(/<\/p>/gi, '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<li[^>]*>/gi, '\n- ')
|
||||
.replace(/<\/li>/gi, '')
|
||||
.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '$2 ($1)')
|
||||
.replace(/<[^>]+>/g, '');
|
||||
return decodeEntities(stripped)
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function authorName(owner) {
|
||||
return decodeEntities(owner?.display_name || '') || '[deleted]';
|
||||
}
|
||||
|
||||
function truncate(text, maxLength) {
|
||||
if (!text || text.length <= maxLength) return text || '';
|
||||
return text.slice(0, maxLength) + ' ... [truncated]';
|
||||
}
|
||||
|
||||
function indentLines(text, depth) {
|
||||
if (depth === 0) return text;
|
||||
const indent = ' '.repeat(depth);
|
||||
const prefix = `${indent}> `;
|
||||
return text.split('\n').map((line) => prefix + line).join('\n');
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'stackoverflow',
|
||||
name: 'read',
|
||||
access: 'read',
|
||||
description: 'Read a Stack Overflow question with answers and comments',
|
||||
domain: 'stackoverflow.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'id', required: true, positional: true, help: 'Stack Overflow question id (numeric, e.g. 79935770)' },
|
||||
{ name: 'answers-limit', type: 'int', default: 10, help: 'Max answers to include (1-100; accepted answer always included first)' },
|
||||
{ name: 'comments-limit', type: 'int', default: 5, help: 'Max comments per question/answer (1-100)' },
|
||||
{ name: 'max-length', type: 'int', default: 4000, help: 'Max characters per body / answer / comment (min 100)' },
|
||||
],
|
||||
columns: ['type', 'author', 'score', 'accepted', 'text'],
|
||||
func: async (args) => {
|
||||
const id = String(args.id || '').trim();
|
||||
if (!/^\d+$/.test(id)) {
|
||||
throw new ArgumentError(`Invalid Stack Overflow question id: ${args.id}`, 'Pass a numeric id like 79935770');
|
||||
}
|
||||
const answersLimit = requireBoundedInt(args['answers-limit'] ?? 10, 1, SE_MAX_PAGE_SIZE, 'stackoverflow read --answers-limit');
|
||||
const commentsLimit = requireBoundedInt(args['comments-limit'] ?? 5, 1, SE_MAX_PAGE_SIZE, 'stackoverflow read --comments-limit');
|
||||
const maxLength = requireMinInt(args['max-length'] ?? 4000, 100, 'stackoverflow read --max-length');
|
||||
|
||||
const label = `stackoverflow/${id}`;
|
||||
const qUrl = `${SE_API_BASE}/questions/${id}?site=${SE_SITE}&filter=withbody`;
|
||||
const qData = await fetchJson(qUrl, label);
|
||||
const question = (qData.items || [])[0];
|
||||
if (!question) {
|
||||
throw new EmptyResultError(label, 'Question not found');
|
||||
}
|
||||
|
||||
// Fetch question comments and answers in parallel.
|
||||
const [qCommentsData, answersData] = await Promise.all([
|
||||
fetchJson(
|
||||
`${SE_API_BASE}/questions/${id}/comments?site=${SE_SITE}&filter=withbody&order=asc&sort=creation&pagesize=${commentsLimit}`,
|
||||
`${label}/comments`,
|
||||
),
|
||||
fetchJson(
|
||||
`${SE_API_BASE}/questions/${id}/answers?site=${SE_SITE}&filter=withbody&order=desc&sort=votes&pagesize=${answersLimit}`,
|
||||
`${label}/answers`,
|
||||
),
|
||||
]);
|
||||
|
||||
const allAnswers = await fetchMissingAcceptedAnswer(question, answersData.items || [], label);
|
||||
// Surface accepted answer first, then by score order.
|
||||
const orderedAnswers = byAcceptedThenScoreDesc(question, allAnswers).slice(0, answersLimit);
|
||||
|
||||
const answerCommentsByAnswerId = await fetchAnswerCommentsByAnswerId(orderedAnswers, commentsLimit, label);
|
||||
|
||||
const rows = [];
|
||||
|
||||
// POST row: question
|
||||
const qBody = htmlToText(question.body || '');
|
||||
const qTextParts = [
|
||||
question.title || '',
|
||||
qBody,
|
||||
question.link || '',
|
||||
].filter(Boolean);
|
||||
rows.push({
|
||||
type: 'POST',
|
||||
author: authorName(question.owner),
|
||||
score: question.score ?? 0,
|
||||
accepted: '',
|
||||
text: truncate(qTextParts.join('\n\n'), maxLength),
|
||||
});
|
||||
|
||||
// Q-COMMENT rows
|
||||
const qComments = (qCommentsData.items || []).slice(0, commentsLimit);
|
||||
for (const c of qComments) {
|
||||
const text = indentLines(htmlToText(c.body || ''), 1);
|
||||
rows.push({
|
||||
type: 'Q-COMMENT',
|
||||
author: authorName(c.owner),
|
||||
score: c.score ?? 0,
|
||||
accepted: '',
|
||||
text: truncate(text, maxLength),
|
||||
});
|
||||
}
|
||||
|
||||
// ANSWER + A-COMMENT rows
|
||||
for (const ans of orderedAnswers) {
|
||||
rows.push({
|
||||
type: 'ANSWER',
|
||||
author: authorName(ans.owner),
|
||||
score: ans.score ?? 0,
|
||||
accepted: ans.is_accepted ? 'true' : '',
|
||||
text: truncate(htmlToText(ans.body || ''), maxLength),
|
||||
});
|
||||
const ansComments = (answerCommentsByAnswerId.get(ans.answer_id) || []).slice(0, commentsLimit);
|
||||
for (const c of ansComments) {
|
||||
const text = indentLines(htmlToText(c.body || ''), 1);
|
||||
rows.push({
|
||||
type: 'A-COMMENT',
|
||||
author: authorName(c.owner),
|
||||
score: c.score ?? 0,
|
||||
accepted: '',
|
||||
text: truncate(text, maxLength),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// stackoverflow related — find Stack Overflow questions related to a given question id.
|
||||
//
|
||||
// Hits the public `/questions/{id}/related` endpoint. Useful for follow-up
|
||||
// research from a single SO question — agents can read one question with
|
||||
// `stackoverflow read`, then expand the search to related/duplicate threads
|
||||
// without rerunning a free-text search.
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
seFetch,
|
||||
normalizeLimit,
|
||||
epochToDate,
|
||||
ensureItems,
|
||||
decodeHtmlEntities,
|
||||
} from './utils.js';
|
||||
|
||||
const SORT_OPTIONS = ['rank', 'activity', 'votes', 'creation'];
|
||||
|
||||
cli({
|
||||
site: 'stackoverflow',
|
||||
name: 'related',
|
||||
access: 'read',
|
||||
description: 'List Stack Overflow questions related to a given question id.',
|
||||
domain: 'stackoverflow.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'id', positional: true, required: true, type: 'string', help: 'Stack Overflow question id (numeric, e.g. 79935770).' },
|
||||
{ name: 'sort', type: 'string', default: 'rank', help: `Sort key: ${SORT_OPTIONS.join(', ')} (rank = SO relevance default).` },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Max related questions (1-100).' },
|
||||
],
|
||||
columns: ['rank', 'id', 'title', 'score', 'answers', 'views', 'isAnswered', 'tags', 'author', 'createdAt', 'lastActivityAt', 'url'],
|
||||
func: async (args) => {
|
||||
const id = String(args.id ?? '').trim();
|
||||
if (!/^\d+$/.test(id)) {
|
||||
throw new ArgumentError(`stackoverflow related id must be a numeric question id, got ${JSON.stringify(args.id)}`);
|
||||
}
|
||||
const sort = String(args.sort ?? 'rank').toLowerCase();
|
||||
if (!SORT_OPTIONS.includes(sort)) {
|
||||
throw new ArgumentError(`stackoverflow related sort must be one of ${SORT_OPTIONS.join(', ')}`);
|
||||
}
|
||||
const limit = normalizeLimit(args.limit, 20, 100, 'limit');
|
||||
const data = await seFetch(`/questions/${encodeURIComponent(id)}/related`, {
|
||||
searchParams: {
|
||||
order: 'desc',
|
||||
sort,
|
||||
pagesize: limit,
|
||||
},
|
||||
});
|
||||
const items = ensureItems(data, `stackoverflow related ${id}`);
|
||||
return items.slice(0, limit).map((q, i) => ({
|
||||
rank: i + 1,
|
||||
id: q.question_id,
|
||||
title: decodeHtmlEntities(q.title || ''),
|
||||
score: q.score ?? 0,
|
||||
answers: q.answer_count ?? 0,
|
||||
views: q.view_count ?? 0,
|
||||
isAnswered: Boolean(q.is_answered),
|
||||
tags: Array.isArray(q.tags) ? q.tags.join(', ') : '',
|
||||
author: decodeHtmlEntities(q.owner?.display_name || ''),
|
||||
createdAt: epochToDate(q.creation_date),
|
||||
lastActivityAt: epochToDate(q.last_activity_date),
|
||||
url: q.link || (q.question_id ? `https://stackoverflow.com/questions/${q.question_id}` : ''),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'stackoverflow',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: 'Search Stack Overflow questions',
|
||||
domain: 'stackoverflow.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'query', type: 'string', required: true, positional: true, help: 'Search query' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max number of results' },
|
||||
],
|
||||
columns: ['rank', 'id', 'title', 'score', 'answers', 'views', 'is_answered', 'tags', 'author', 'creation_date', 'url'],
|
||||
pipeline: [
|
||||
{ fetch: {
|
||||
url: 'https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q=${{ args.query }}&site=stackoverflow&pagesize=${{ args.limit }}',
|
||||
} },
|
||||
{ select: 'items' },
|
||||
{ map: {
|
||||
rank: '${{ index + 1 }}',
|
||||
id: '${{ item.question_id }}',
|
||||
title: '${{ item.title }}',
|
||||
score: '${{ item.score }}',
|
||||
answers: '${{ item.answer_count }}',
|
||||
views: '${{ item.view_count }}',
|
||||
is_answered: '${{ item.is_answered }}',
|
||||
tags: `\${{ item.tags | join(', ') }}`,
|
||||
author: '${{ item.owner.display_name }}',
|
||||
creation_date: '${{ item.creation_date }}',
|
||||
url: '${{ item.link }}',
|
||||
} },
|
||||
{ limit: '${{ args.limit }}' },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,404 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import './hot.js';
|
||||
import './search.js';
|
||||
import './unanswered.js';
|
||||
import './bounties.js';
|
||||
import './read.js';
|
||||
import './tag.js';
|
||||
import './user.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('stackoverflow listing adapters surface question_id/tags/views/owner', () => {
|
||||
it('stackoverflow/hot has the agent-native column shape', () => {
|
||||
const cmd = getRegistry().get('stackoverflow/hot');
|
||||
expect(cmd?.columns).toEqual([
|
||||
'rank', 'id', 'title', 'score', 'answers', 'views',
|
||||
'is_answered', 'tags', 'author', 'creation_date', 'url',
|
||||
]);
|
||||
const mapStep = cmd?.pipeline?.find((step) => step.map);
|
||||
expect(mapStep?.map).toMatchObject({
|
||||
id: '${{ item.question_id }}',
|
||||
views: '${{ item.view_count }}',
|
||||
is_answered: '${{ item.is_answered }}',
|
||||
author: '${{ item.owner.display_name }}',
|
||||
creation_date: '${{ item.creation_date }}',
|
||||
});
|
||||
});
|
||||
|
||||
it('stackoverflow/search has the agent-native column shape', () => {
|
||||
const cmd = getRegistry().get('stackoverflow/search');
|
||||
expect(cmd?.columns).toEqual([
|
||||
'rank', 'id', 'title', 'score', 'answers', 'views',
|
||||
'is_answered', 'tags', 'author', 'creation_date', 'url',
|
||||
]);
|
||||
const mapStep = cmd?.pipeline?.find((step) => step.map);
|
||||
expect(mapStep?.map).toMatchObject({
|
||||
id: '${{ item.question_id }}',
|
||||
views: '${{ item.view_count }}',
|
||||
});
|
||||
});
|
||||
|
||||
it('stackoverflow/unanswered drops is_answered (always false) but keeps the rest', () => {
|
||||
const cmd = getRegistry().get('stackoverflow/unanswered');
|
||||
expect(cmd?.columns).toEqual([
|
||||
'rank', 'id', 'title', 'score', 'answers', 'views',
|
||||
'tags', 'author', 'creation_date', 'url',
|
||||
]);
|
||||
expect(cmd?.columns).not.toContain('is_answered');
|
||||
});
|
||||
|
||||
it('stackoverflow/bounties keeps the bounty column at the front', () => {
|
||||
const cmd = getRegistry().get('stackoverflow/bounties');
|
||||
expect(cmd?.columns).toEqual([
|
||||
'rank', 'id', 'bounty', 'title', 'score', 'answers', 'views',
|
||||
'is_answered', 'tags', 'author', 'creation_date', 'url',
|
||||
]);
|
||||
const mapStep = cmd?.pipeline?.find((step) => step.map);
|
||||
expect(mapStep?.map).toMatchObject({
|
||||
id: '${{ item.question_id }}',
|
||||
bounty: '${{ item.bounty_amount }}',
|
||||
});
|
||||
});
|
||||
|
||||
it('stackoverflow/tag exposes id so rows round-trip into read <id>', async () => {
|
||||
const cmd = getRegistry().get('stackoverflow/tag');
|
||||
expect(cmd?.columns).toEqual([
|
||||
'rank', 'id', 'title', 'score', 'answers', 'views',
|
||||
'isAnswered', 'tags', 'author', 'createdAt', 'lastActivityAt', 'url',
|
||||
]);
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
items: [{
|
||||
question_id: 123,
|
||||
title: 'Rust & async',
|
||||
score: 5,
|
||||
answer_count: 2,
|
||||
view_count: 100,
|
||||
is_answered: true,
|
||||
tags: ['rust', 'async'],
|
||||
owner: { display_name: 'Ferris's friend' },
|
||||
creation_date: 1700000000,
|
||||
last_activity_date: 1700003600,
|
||||
link: 'https://stackoverflow.com/questions/123/rust-async',
|
||||
}],
|
||||
}), { status: 200 }),
|
||||
));
|
||||
|
||||
const rows = await cmd.func({ tag: 'rust', sort: 'activity', limit: 1 });
|
||||
|
||||
expect(rows).toEqual([expect.objectContaining({
|
||||
id: 123,
|
||||
title: 'Rust & async',
|
||||
author: "Ferris's friend",
|
||||
url: 'https://stackoverflow.com/questions/123/rust-async',
|
||||
})]);
|
||||
expect(rows[0]).not.toHaveProperty('questionId');
|
||||
});
|
||||
|
||||
it('stackoverflow/tag rejects bad sort and limit before fetching', async () => {
|
||||
const cmd = getRegistry().get('stackoverflow/tag');
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(cmd.func({ tag: 'rust', sort: 'invalid', limit: 1 }))
|
||||
.rejects.toThrow(ArgumentError);
|
||||
await expect(cmd.func({ tag: 'rust', sort: 'activity', limit: 101 }))
|
||||
.rejects.toThrow(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stackoverflow/user exposes userId profile rows without pretending they feed read <id>', async () => {
|
||||
const cmd = getRegistry().get('stackoverflow/user');
|
||||
expect(cmd?.columns).toEqual([
|
||||
'userId', 'displayName', 'reputation', 'goldBadges', 'silverBadges',
|
||||
'bronzeBadges', 'location', 'createdAt', 'lastAccessAt', 'url',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stackoverflow/read adapter', () => {
|
||||
const cmd = getRegistry().get('stackoverflow/read');
|
||||
|
||||
it('registers the question/answer/comment row shape', () => {
|
||||
expect(cmd?.columns).toEqual(['type', 'author', 'score', 'accepted', 'text']);
|
||||
});
|
||||
|
||||
it('takes a positional id plus tunable answers-limit/comments-limit/max-length', () => {
|
||||
const argNames = (cmd?.args || []).map((a) => a.name);
|
||||
expect(argNames).toEqual(['id', 'answers-limit', 'comments-limit', 'max-length']);
|
||||
const idArg = cmd?.args?.find((a) => a.name === 'id');
|
||||
expect(idArg?.required).toBe(true);
|
||||
expect(idArg?.positional).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the public Stack Exchange API (no browser, public strategy)', () => {
|
||||
expect(cmd?.browser).toBe(false);
|
||||
expect(cmd?.strategy).toBe('public');
|
||||
});
|
||||
|
||||
it('fails fast with ArgumentError for non-numeric id before fetching', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(cmd.func({ id: 'not-a-number', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 }))
|
||||
.rejects.toThrow(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails fast with ArgumentError for max-length below 100 before fetching', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 50 }))
|
||||
.rejects.toThrow(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails fast with EmptyResultError when the question lookup returns empty items', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ items: [] }), { status: 200 }),
|
||||
));
|
||||
|
||||
await expect(cmd.func({ id: '99999999', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 }))
|
||||
.rejects.toThrow(EmptyResultError);
|
||||
});
|
||||
|
||||
it('surfaces Stack Exchange API throttle / quota errors as CommandExecutionError', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error_id: 502, error_name: 'throttle_violation', error_message: 'too fast' }), { status: 200 }),
|
||||
));
|
||||
|
||||
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 }))
|
||||
.rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('wraps fetch network failures in CommandExecutionError (not raw TypeError)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
|
||||
|
||||
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 }))
|
||||
.rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('wraps malformed JSON responses in CommandExecutionError (not raw SyntaxError)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||
new Response('<!DOCTYPE html><html>maintenance</html>', { status: 200 }),
|
||||
));
|
||||
|
||||
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 }))
|
||||
.rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('coerces and validates string-form numeric args (e.g. "50" not Number(50))', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
// String "50" should still be rejected because it's < 100
|
||||
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': 5, 'max-length': '50' }))
|
||||
.rejects.toThrow(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
// String "abc" should also be rejected
|
||||
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 'abc' }))
|
||||
.rejects.toThrow(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects answer/comment limits above Stack Exchange pagesize max before fetching', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(cmd.func({ id: '12345', 'answers-limit': '101', 'comments-limit': 5, 'max-length': 4000 }))
|
||||
.rejects.toThrow(ArgumentError);
|
||||
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': '101', 'max-length': 4000 }))
|
||||
.rejects.toThrow(ArgumentError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds POST + Q-COMMENT + ANSWER + A-COMMENT rows, accepted answer first', async () => {
|
||||
const question = {
|
||||
items: [{
|
||||
question_id: 1,
|
||||
title: 'Why?',
|
||||
body: '<p>Question body</p>',
|
||||
score: 10,
|
||||
link: 'https://example.com/q/1',
|
||||
owner: { display_name: 'asker' },
|
||||
}],
|
||||
};
|
||||
const qComments = {
|
||||
items: [
|
||||
{ score: 2, owner: { display_name: 'qc1' }, body: '<p>q comment one</p>' },
|
||||
{ score: 1, owner: { display_name: 'qc2' }, body: '<p>q comment two</p>' },
|
||||
],
|
||||
};
|
||||
const answers = {
|
||||
items: [
|
||||
{ answer_id: 100, score: 5, is_accepted: false, owner: { display_name: 'low' }, body: '<p>low score answer</p>' },
|
||||
{ answer_id: 200, score: 50, is_accepted: true, owner: { display_name: 'winner' }, body: '<p>accepted answer</p>' },
|
||||
],
|
||||
};
|
||||
const answerComments = {
|
||||
items: [
|
||||
{ post_id: 200, score: 1, owner: { display_name: 'ac1' }, body: '<p>comment on accepted</p>' },
|
||||
{ post_id: 100, score: 0, owner: { display_name: 'ac2' }, body: '<p>comment on low</p>' },
|
||||
],
|
||||
};
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(question), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(qComments), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(answers), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(answerComments), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const rows = await cmd.func({ id: '1', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 });
|
||||
|
||||
expect(rows.map((r) => [r.type, r.author, r.accepted])).toEqual([
|
||||
['POST', 'asker', ''],
|
||||
['Q-COMMENT', 'qc1', ''],
|
||||
['Q-COMMENT', 'qc2', ''],
|
||||
['ANSWER', 'winner', 'true'], // accepted comes FIRST
|
||||
['A-COMMENT', 'ac1', ''],
|
||||
['ANSWER', 'low', ''],
|
||||
['A-COMMENT', 'ac2', ''],
|
||||
]);
|
||||
|
||||
// Verify the answer-comments fetch batched both answer ids
|
||||
const ansCommentsCall = fetchMock.mock.calls[3][0];
|
||||
expect(ansCommentsCall).toContain('/answers/200;100/comments');
|
||||
expect(fetchMock.mock.calls[1][0]).toContain('pagesize=5');
|
||||
expect(fetchMock.mock.calls[2][0]).toContain('pagesize=10');
|
||||
expect(fetchMock.mock.calls[3][0]).toContain('pagesize=10');
|
||||
});
|
||||
|
||||
it('fetches accepted answer separately when it is missing from the votes page', async () => {
|
||||
const question = {
|
||||
items: [{
|
||||
question_id: 1,
|
||||
accepted_answer_id: 999,
|
||||
title: 'Why?',
|
||||
body: '<p>Question body</p>',
|
||||
score: 10,
|
||||
link: 'https://example.com/q/1',
|
||||
owner: { display_name: 'asker' },
|
||||
}],
|
||||
};
|
||||
const answers = {
|
||||
items: [
|
||||
{ answer_id: 100, score: 50, is_accepted: false, owner: { display_name: 'top-voted' }, body: '<p>top voted answer</p>' },
|
||||
],
|
||||
};
|
||||
const acceptedAnswer = {
|
||||
items: [
|
||||
{ answer_id: 999, score: 1, is_accepted: true, owner: { display_name: 'accepted' }, body: '<p>accepted answer</p>' },
|
||||
],
|
||||
};
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(question), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(answers), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(acceptedAnswer), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const rows = await cmd.func({ id: '1', 'answers-limit': 2, 'comments-limit': 5, 'max-length': 4000 });
|
||||
|
||||
expect(rows.filter((r) => r.type === 'ANSWER').map((r) => [r.author, r.accepted])).toEqual([
|
||||
['accepted', 'true'],
|
||||
['top-voted', ''],
|
||||
]);
|
||||
expect(fetchMock.mock.calls[3][0]).toContain('/answers/999?');
|
||||
expect(fetchMock.mock.calls[4][0]).toContain('/answers/999;100/comments');
|
||||
expect(fetchMock.mock.calls[4][0]).toContain('pagesize=10');
|
||||
});
|
||||
|
||||
it('fails fast when batched answer comments would be partial', async () => {
|
||||
const question = {
|
||||
items: [{
|
||||
question_id: 1,
|
||||
title: 'Why?',
|
||||
body: '<p>Question body</p>',
|
||||
score: 10,
|
||||
link: 'https://example.com/q/1',
|
||||
owner: { display_name: 'asker' },
|
||||
}],
|
||||
};
|
||||
const answers = {
|
||||
items: [
|
||||
{ answer_id: 100, score: 50, is_accepted: false, owner: { display_name: 'a1' }, body: '<p>one</p>' },
|
||||
{ answer_id: 200, score: 40, is_accepted: false, owner: { display_name: 'a2' }, body: '<p>two</p>' },
|
||||
],
|
||||
};
|
||||
const answerComments = {
|
||||
has_more: true,
|
||||
items: [
|
||||
{ post_id: 100, score: 1, owner: { display_name: 'c1' }, body: '<p>comment on first</p>' },
|
||||
],
|
||||
};
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(question), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(answers), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(answerComments), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(cmd.func({ id: '1', 'answers-limit': 2, 'comments-limit': 1, 'max-length': 4000 }))
|
||||
.rejects.toThrow(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('decodes HTML entities in body and display_name (named, decimal, hex)', async () => {
|
||||
const question = {
|
||||
items: [{
|
||||
question_id: 1,
|
||||
title: 't',
|
||||
body: '<p>price < & … ö 'ok'</p>',
|
||||
score: 0,
|
||||
link: '',
|
||||
owner: { display_name: 'Jonas Kölker' },
|
||||
}],
|
||||
};
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(question), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const rows = await cmd.func({ id: '1', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 });
|
||||
|
||||
expect(rows[0].author).toBe('Jonas Kölker');
|
||||
expect(rows[0].text).toContain('price < & … ö \'ok\'');
|
||||
});
|
||||
|
||||
it('respects answers-limit when there are more answers than the cap', async () => {
|
||||
const question = {
|
||||
items: [{
|
||||
question_id: 1, title: 't', body: '', score: 0, link: '',
|
||||
owner: { display_name: 'a' },
|
||||
}],
|
||||
};
|
||||
const answers = {
|
||||
items: [
|
||||
{ answer_id: 100, score: 5, is_accepted: false, owner: { display_name: 'a1' }, body: '' },
|
||||
{ answer_id: 200, score: 4, is_accepted: false, owner: { display_name: 'a2' }, body: '' },
|
||||
{ answer_id: 300, score: 3, is_accepted: false, owner: { display_name: 'a3' }, body: '' },
|
||||
],
|
||||
};
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(question), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(answers), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const rows = await cmd.func({ id: '1', 'answers-limit': 2, 'comments-limit': 5, 'max-length': 4000 });
|
||||
|
||||
const answerRows = rows.filter((r) => r.type === 'ANSWER');
|
||||
expect(answerRows.map((r) => r.author)).toEqual(['a1', 'a2']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// stackoverflow tag — list questions tagged with a given tag (most active first).
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
seFetch,
|
||||
normalizeLimit,
|
||||
requireString,
|
||||
epochToDate,
|
||||
ensureItems,
|
||||
decodeHtmlEntities,
|
||||
} from './utils.js';
|
||||
|
||||
const SORT_OPTIONS = ['activity', 'votes', 'creation', 'hot', 'week', 'month'];
|
||||
|
||||
cli({
|
||||
site: 'stackoverflow',
|
||||
name: 'tag',
|
||||
access: 'read',
|
||||
description: 'List Stack Overflow questions tagged with a given tag (most active first).',
|
||||
domain: 'stackoverflow.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'tag', positional: true, required: true, type: 'string', help: 'Tag slug (e.g. python, rust, typescript).' },
|
||||
{ name: 'sort', type: 'string', default: 'activity', help: `Sort key: ${SORT_OPTIONS.join(', ')}` },
|
||||
{ name: 'limit', type: 'int', default: 20, help: 'Max questions to return (max 100).' },
|
||||
],
|
||||
columns: ['rank', 'id', 'title', 'score', 'answers', 'views', 'isAnswered', 'tags', 'author', 'createdAt', 'lastActivityAt', 'url'],
|
||||
func: async (args) => {
|
||||
const tag = requireString(args.tag, 'tag').toLowerCase();
|
||||
const sort = String(args.sort ?? 'activity').toLowerCase();
|
||||
if (!SORT_OPTIONS.includes(sort)) {
|
||||
throw new ArgumentError(`sort must be one of ${SORT_OPTIONS.join(', ')}`);
|
||||
}
|
||||
const limit = normalizeLimit(args.limit, 20, 100, 'limit');
|
||||
const data = await seFetch(`/questions`, {
|
||||
searchParams: {
|
||||
tagged: tag,
|
||||
order: 'desc',
|
||||
sort,
|
||||
pagesize: limit,
|
||||
},
|
||||
});
|
||||
const items = ensureItems(data, `stackoverflow tag "${tag}"`);
|
||||
return items.slice(0, limit).map((q, i) => ({
|
||||
rank: i + 1,
|
||||
id: q.question_id,
|
||||
title: decodeHtmlEntities(q.title || ''),
|
||||
score: q.score ?? 0,
|
||||
answers: q.answer_count ?? 0,
|
||||
views: q.view_count ?? 0,
|
||||
isAnswered: Boolean(q.is_answered),
|
||||
tags: Array.isArray(q.tags) ? q.tags.join(', ') : '',
|
||||
author: decodeHtmlEntities(q.owner?.display_name || ''),
|
||||
createdAt: epochToDate(q.creation_date),
|
||||
lastActivityAt: epochToDate(q.last_activity_date),
|
||||
url: q.link || (q.question_id ? `https://stackoverflow.com/questions/${q.question_id}` : ''),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
cli({
|
||||
site: 'stackoverflow',
|
||||
name: 'unanswered',
|
||||
access: 'read',
|
||||
description: 'Top voted unanswered questions on Stack Overflow',
|
||||
domain: 'stackoverflow.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max number of results' },
|
||||
],
|
||||
columns: ['rank', 'id', 'title', 'score', 'answers', 'views', 'tags', 'author', 'creation_date', 'url'],
|
||||
pipeline: [
|
||||
{ fetch: {
|
||||
url: 'https://api.stackexchange.com/2.3/questions/unanswered?order=desc&sort=votes&site=stackoverflow&pagesize=${{ args.limit }}',
|
||||
} },
|
||||
{ select: 'items' },
|
||||
{ map: {
|
||||
rank: '${{ index + 1 }}',
|
||||
id: '${{ item.question_id }}',
|
||||
title: '${{ item.title }}',
|
||||
score: '${{ item.score }}',
|
||||
answers: '${{ item.answer_count }}',
|
||||
views: '${{ item.view_count }}',
|
||||
tags: `\${{ item.tags | join(', ') }}`,
|
||||
author: '${{ item.owner.display_name }}',
|
||||
creation_date: '${{ item.creation_date }}',
|
||||
url: '${{ item.link }}',
|
||||
} },
|
||||
{ limit: '${{ args.limit }}' },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// stackoverflow user — search Stack Overflow users by name and return profiles.
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
seFetch,
|
||||
normalizeLimit,
|
||||
requireString,
|
||||
epochToDate,
|
||||
ensureItems,
|
||||
decodeHtmlEntities,
|
||||
} from './utils.js';
|
||||
|
||||
cli({
|
||||
site: 'stackoverflow',
|
||||
name: 'user',
|
||||
access: 'read',
|
||||
description: 'Find Stack Overflow users by display name (highest reputation first).',
|
||||
domain: 'stackoverflow.com',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'name', positional: true, required: true, type: 'string', help: 'Display name (or substring) to search.' },
|
||||
{ name: 'limit', type: 'int', default: 10, help: 'Max users to return (max 100).' },
|
||||
],
|
||||
columns: ['userId', 'displayName', 'reputation', 'goldBadges', 'silverBadges', 'bronzeBadges', 'location', 'createdAt', 'lastAccessAt', 'url'],
|
||||
func: async (args) => {
|
||||
const name = requireString(args.name, 'name');
|
||||
const limit = normalizeLimit(args.limit, 10, 100, 'limit');
|
||||
const data = await seFetch('/users', {
|
||||
searchParams: {
|
||||
inname: name,
|
||||
order: 'desc',
|
||||
sort: 'reputation',
|
||||
pagesize: limit,
|
||||
},
|
||||
});
|
||||
const items = ensureItems(data, 'stackoverflow user');
|
||||
return items.slice(0, limit).map((u) => ({
|
||||
userId: u.user_id,
|
||||
displayName: decodeHtmlEntities(u.display_name || ''),
|
||||
reputation: u.reputation ?? 0,
|
||||
goldBadges: u.badge_counts?.gold ?? 0,
|
||||
silverBadges: u.badge_counts?.silver ?? 0,
|
||||
bronzeBadges: u.badge_counts?.bronze ?? 0,
|
||||
location: decodeHtmlEntities(u.location || ''),
|
||||
createdAt: epochToDate(u.creation_date),
|
||||
lastAccessAt: epochToDate(u.last_access_date),
|
||||
url: u.link || (u.user_id ? `https://stackoverflow.com/users/${u.user_id}` : ''),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// Shared helpers for stackoverflow adapters using the Stack Exchange API.
|
||||
//
|
||||
// Public endpoint (api.stackexchange.com 2.3) accepts unauthenticated traffic
|
||||
// up to 300 requests/day per IP for read endpoints, plenty for ad-hoc CLI use.
|
||||
// We always set `site=stackoverflow` and decode the gzipped/HTML body via the
|
||||
// returned JSON envelope.
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
|
||||
export const SE_API = 'https://api.stackexchange.com/2.3';
|
||||
export const SE_SITE = 'stackoverflow';
|
||||
|
||||
const UA = 'opencli-stackoverflow (+https://github.com/jackwener/opencli)';
|
||||
|
||||
/** Validate `limit` per typed-fail-fast convention (no silent clamp). */
|
||||
export function normalizeLimit(value, defaultValue, maxValue, label = 'limit') {
|
||||
const raw = value ?? defaultValue;
|
||||
const limit = Number(raw);
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
throw new ArgumentError(`${label} must be a positive integer`);
|
||||
}
|
||||
if (limit > maxValue) {
|
||||
throw new ArgumentError(`${label} must be <= ${maxValue}`);
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
export function requireString(value, label) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) {
|
||||
throw new ArgumentError(`${label} cannot be empty`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** Fetch a Stack Exchange API endpoint and return parsed JSON envelope. */
|
||||
export async function seFetch(path, { searchParams } = {}) {
|
||||
const url = new URL(path.startsWith('http') ? path : `${SE_API}${path.startsWith('/') ? '' : '/'}${path}`);
|
||||
if (searchParams) {
|
||||
for (const [k, v] of Object.entries(searchParams)) {
|
||||
if (v == null || v === '') continue;
|
||||
url.searchParams.set(k, String(v));
|
||||
}
|
||||
}
|
||||
if (!url.searchParams.has('site')) url.searchParams.set('site', SE_SITE);
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'User-Agent': UA,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`stack exchange request failed: ${error?.message || error}`);
|
||||
}
|
||||
if (resp.status === 429) {
|
||||
throw new CommandExecutionError('stack exchange returned HTTP 429 (rate limited)', 'Wait a few seconds and retry, or lower --limit.');
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let body = '';
|
||||
try { body = (await resp.json())?.error_message || ''; } catch { /* ignore */ }
|
||||
throw new CommandExecutionError(`stack exchange HTTP ${resp.status}: ${body || resp.statusText}`);
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch (error) {
|
||||
throw new CommandExecutionError(`stack exchange returned malformed JSON: ${error?.message || error}`);
|
||||
}
|
||||
if (data?.error_id) {
|
||||
throw new CommandExecutionError(
|
||||
`stack exchange API error: ${data.error_message || data.error_name}`,
|
||||
'Inspect the URL in a browser for the canonical error context.',
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Convert SE epoch seconds to YYYY-MM-DD. */
|
||||
export function epochToDate(value) {
|
||||
if (value == null || value === '') return '';
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return '';
|
||||
return new Date(n * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Throw EmptyResultError when an /items array is empty. */
|
||||
export function ensureItems(data, label) {
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
if (items.length === 0) {
|
||||
throw new EmptyResultError(label, `${label} returned no items.`);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const HTML_ENTITY_MAP = {
|
||||
'&': '&', '<': '<', '>': '>', '"': '"',
|
||||
''': "'", ''': "'", ' ': ' ',
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode the small set of HTML entities Stack Exchange emits in display
|
||||
* names and titles (e.g. "Jon Skeet's mentor"). Decimal/hex numeric
|
||||
* refs are also handled.
|
||||
*/
|
||||
export function decodeHtmlEntities(value) {
|
||||
if (value == null) return '';
|
||||
return String(value)
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
||||
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
||||
.replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (m) => HTML_ENTITY_MAP[m] || m);
|
||||
}
|
||||
Reference in New Issue
Block a user