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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:48 +08:00
commit 9b395f5cc3
2400 changed files with 376116 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# check-doc-coverage.sh — Verify every adapter in clis/ has a doc page.
#
# Exit codes:
# 0 — all adapters have docs
# 1 — at least one adapter is missing documentation
#
# Usage:
# bash scripts/check-doc-coverage.sh # report only
# bash scripts/check-doc-coverage.sh --strict # exit 1 on missing docs
set -euo pipefail
STRICT=false
if [[ "${1:-}" == "--strict" ]]; then
STRICT=true
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SRC_DIR="$ROOT_DIR/clis"
DOCS_DIR="$ROOT_DIR/docs/adapters"
missing=()
covered=0
total=0
for adapter_dir in "$SRC_DIR"/*/; do
adapter_name="$(basename "$adapter_dir")"
# Skip internal directories (e.g., _shared)
[[ "$adapter_name" == _* ]] && continue
# Skip directories that only contain utility files (prefixed with _)
has_commands=false
for f in "$adapter_dir"*; do
fname="$(basename "$f")"
[[ "$fname" == _* ]] && continue
[[ "$fname" == *.test.* ]] && continue
has_commands=true
break
done
[[ "$has_commands" == false ]] && continue
total=$((total + 1))
# Check if doc exists in browser/ or desktop/ subdirectories
if [[ -f "$DOCS_DIR/browser/$adapter_name.md" ]] || \
[[ -f "$DOCS_DIR/desktop/$adapter_name.md" ]]; then
covered=$((covered + 1))
else
# Handle directory name mismatches (e.g., discord-app -> discord)
alt_name="${adapter_name%-app}"
if [[ "$alt_name" != "$adapter_name" ]] && \
{ [[ -f "$DOCS_DIR/browser/$alt_name.md" ]] || \
[[ -f "$DOCS_DIR/desktop/$alt_name.md" ]]; }; then
covered=$((covered + 1))
else
missing+=("$adapter_name")
fi
fi
done
echo "📊 Doc Coverage: $covered/$total adapters documented"
echo ""
if [[ ${#missing[@]} -gt 0 ]]; then
echo "⚠️ Missing docs for ${#missing[@]} adapter(s):"
for name in "${missing[@]}"; do
echo " - $name → create docs/adapters/browser/$name.md or docs/adapters/desktop/$name.md"
done
echo ""
if $STRICT; then
echo "❌ Doc check failed (--strict mode)."
exit 1
else
echo "💡 Run with --strict to fail CI on missing docs."
exit 0
fi
else
echo "✅ All adapters have documentation."
exit 0
fi
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env node
/**
* check-listing-id-pairing.mjs — advisory report on listing↔detail id round-tripping.
*
* Soft convention (NOT a CI gate): when a site exposes both a listing-class
* command (search / hot / recent / trending / top / feed / popular / new /
* list) AND a detail-class command (read / article / paper / post / detail /
* view / job / page / book / movie / show / chapter / question / answer /
* tweet / video / track), it's usually nicer for agents if every listing row
* carries an id-shaped column whose value round-trips into the detail
* command. Without that, the agent has to re-search by title or scrape a URL
* to follow up.
*
* Why advisory and not a gate: whether a listing should pair with a detail
* is a case-by-case product/UX call (topic-string trending, profile-attribute
* key/value rows, UI-only sessions etc. legitimately don't pair). Forcing
* authors through an exempt list every PR was higher cognitive cost than the
* silent-loss bugs the rule actually catches. See PR #1311 thread for the
* "anti-pattern vs case-by-case" filter.
*
* What this script does:
* 1. Group cli-manifest.json entries by site.
* 2. For each site that has both classes, walk every listing entry and
* check `columns` for at least one id-shaped name.
* 3. Print a report. Always exits 0 — never fails CI.
*
* Usage:
* node scripts/check-listing-id-pairing.mjs # print advisory report
* npm run advise:listing-id-pairing
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const MANIFEST = resolve(__dirname, '..', 'cli-manifest.json');
/**
* Listing-class commands. Each row represents a single fetchable entity
* (post / paper / job / ...). The id of that entity must round-trip into
* the site's detail command.
*/
const LISTING_NAMES = new Set([
'search', 'hot', 'recent', 'trending', 'top', 'feed', 'popular',
'list', 'best', 'newest', 'latest', 'rising', 'controversial',
'home', 'timeline', 'browse', 'discover', 'jobs',
'unanswered', 'bounties', 'tag', 'user', 'venue',
'category', 'subreddit', 'question',
]);
/**
* Listing-class commands whose rows are sub-resources within a parent
* thread/session, NOT independently fetchable. Excluded from the rule:
*
* - `comments` / `replies` / `reviews` / `answer-list` / `thread-list`
* — rows are comments under a parent post; the detail command fetches
* the parent, not the comment
* - `ask` / `new` / `show` for AI-chat / agent-session sites — rows are
* conversation turns within one session, not separately addressable
*
* These are intentionally NOT in `LISTING_NAMES` so the rule doesn't
* fire on them.
*/
const DETAIL_NAMES = new Set([
'read', 'article', 'paper', 'post', 'detail', 'view', 'job',
'page', 'book', 'movie', 'show-detail', 'chapter', 'tweet',
'video', 'track', 'note', 'review', 'item', 'product', 'episode',
'thread', 'comment-detail', 'profile-detail', 'shop',
]);
/** Columns whose name implies "this is an id you can pass to detail". */
const ID_COLUMN_PATTERNS = [
/^id$/i,
/_id$/i,
/Id$/,
/^short_id$/i,
/^jk$/i, // indeed
/^tid$/i, // hupu / thread id
/^bvid$/i, // bilibili
/^aid$/i, // anime / bilibili av
/^asin$/i, // amazon
/^sku$/i, // jd / retail product SKU
/^isbn$/i, // book sites
/^doi$/i, // arxiv / openreview
/^slug$/i, // dev.to / lobsters short slug
/^hn_id$/i,
/^username$/i, // user-keyed detail (profile commands)
/^handle$/i,
/^uri$/i, // bluesky AT URI (at://did:.../...)
];
function isUrlDetailCommand(entry) {
const args = Array.isArray(entry.args) ? entry.args : [];
const primaryArg = args.find((arg) => arg?.positional || arg?.required) ?? args[0];
if (!primaryArg) return false;
const name = String(primaryArg.name ?? '').toLowerCase();
if (name === 'url' || name === 'url-or-id') return true;
const help = String(primaryArg.help ?? '').toLowerCase();
if (!help) return false;
// Accept only explicit "this argument may be a URL" wording. Phrases
// like "id from URL" mean callers must extract an id before invoking
// the detail command, so listing.url must not satisfy the id-pair gate.
return (
/^full\b[^()]*\burl\b/.test(help) ||
/\burl\s+or\s+[^()]*\bid\b/.test(help) ||
/\bor\s+(?:a\s+)?full\b[^()]*\burl\b/.test(help) ||
/\bor\s+url\b/.test(help) ||
/\burl\s*,\s*or\b/.test(help)
);
}
function isIdColumn(col, detailCommands) {
if (ID_COLUMN_PATTERNS.some((re) => re.test(col))) return true;
if (/^url$/i.test(col)) {
return detailCommands.some(isUrlDetailCommand);
}
return false;
}
function classify(name) {
if (LISTING_NAMES.has(name)) return 'listing';
if (DETAIL_NAMES.has(name)) return 'detail';
return 'other';
}
function main() {
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
const bySite = new Map();
for (const entry of manifest) {
if (!entry?.site || !entry?.name) continue;
if (!bySite.has(entry.site)) bySite.set(entry.site, []);
bySite.get(entry.site).push(entry);
}
const findings = [];
let scannedSites = 0;
let scannedListings = 0;
for (const [site, entries] of bySite) {
// Only `access: 'read'` detail commands count — write commands like
// `instagram/post` or `instagram/note` create remote state, they don't
// fetch by id, so the listing→detail pairing rule doesn't apply.
const readDetail = entries.filter(
(e) => classify(e.name) === 'detail' && e.access === 'read',
);
const hasListing = entries.some((e) => classify(e.name) === 'listing');
if (!hasListing || readDetail.length === 0) continue;
scannedSites++;
for (const entry of entries) {
if (classify(entry.name) !== 'listing') continue;
scannedListings++;
const columns = Array.isArray(entry.columns) ? entry.columns : [];
if (!columns.some((col) => isIdColumn(col, readDetail))) {
findings.push({
site,
name: entry.name,
columns,
detail: readDetail.map((e) => e.name),
});
}
}
}
console.log(`Scanned ${scannedSites} site(s) with both listing and read-detail commands.`);
console.log(`Checked ${scannedListings} listing command(s).`);
if (findings.length === 0) {
console.log('OK — every listing carries an id-shaped column.');
return;
}
console.log('');
console.log(`Advisory: ${findings.length} listing(s) without a round-trippable id column.`);
console.log('Some of these are legitimate (topic strings, profile-attribute rows, UI-only');
console.log('sessions); others may be worth adding an id to. Use judgment, not a gate.');
console.log('');
for (const v of findings) {
console.log(`${v.site}/${v.name}`);
console.log(` columns: [${v.columns.join(', ')}]`);
console.log(` detail commands on this site: ${v.detail.join(', ')}`);
}
console.log('');
console.log('See docs/conventions/listing-detail-id-pairing.md for context and patterns.');
}
main();
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
/**
* check-silent-column-drop.mjs — CI gate for newly introduced table-output loss.
*
* This gate intentionally uses a baseline. The repo currently has known
* silent-column-drop findings, and blocking every existing violation would make
* the first CI adoption PR too large. The invariant enforced here is:
*
* no new silent-column-drop signatures beyond scripts/silent-column-drop-baseline.json
*
* When a follow-up sweep fixes existing violations, run:
*
* node scripts/check-silent-column-drop.mjs --update-baseline
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '..');
const DIST_AUDIT = resolve(PROJECT_ROOT, 'dist', 'src', 'convention-audit.js');
const BASELINE_PATH = resolve(__dirname, 'silent-column-drop-baseline.json');
const UPDATE = process.argv.includes('--update-baseline');
if (!existsSync(DIST_AUDIT)) {
console.error('dist/src/convention-audit.js not found. Run npm run build before this check.');
process.exit(1);
}
const { runConventionAudit } = await import(pathToFileURL(DIST_AUDIT).href);
const report = runConventionAudit({ projectRoot: PROJECT_ROOT });
const category = report.categories.find((item) => item.rule === 'silent-column-drop');
const current = sortRecords((category?.violations ?? []).map(toBaselineRecord));
if (UPDATE) {
writeFileSync(BASELINE_PATH, `${JSON.stringify(current, null, 2)}\n`);
console.log(`Updated ${relative(BASELINE_PATH)} with ${current.length} silent-column-drop baseline entr${current.length === 1 ? 'y' : 'ies'}.`);
process.exit(0);
}
if (!existsSync(BASELINE_PATH)) {
console.error(`${relative(BASELINE_PATH)} not found. Run node scripts/check-silent-column-drop.mjs --update-baseline.`);
process.exit(1);
}
const baseline = sortRecords(JSON.parse(readFileSync(BASELINE_PATH, 'utf-8')));
const baselineSignatures = new Set(baseline.map(signature));
const currentSignatures = new Set(current.map(signature));
const added = current.filter((record) => !baselineSignatures.has(signature(record)));
const resolved = baseline.filter((record) => !currentSignatures.has(signature(record)));
console.log(`Silent-column-drop gate: current=${current.length}, baseline=${baseline.length}, new=${added.length}, resolved=${resolved.length}`);
if (resolved.length > 0) {
console.log('');
console.log('Resolved baseline entries detected. Consider shrinking the baseline:');
for (const record of resolved) {
console.log(` - ${record.command} ${record.file} missing=[${record.missing.join(', ')}]`);
}
}
if (added.length === 0) {
console.log('OK - no new silent-column-drop violations.');
process.exit(0);
}
console.log('');
console.log('New silent-column-drop violations:');
for (const record of added) {
console.log(` - ${record.command} ${record.file} missing=[${record.missing.join(', ')}]`);
}
console.log('');
console.log('Fix the adapter columns, or if this is an intentional baseline adoption, run:');
console.log(' node scripts/check-silent-column-drop.mjs --update-baseline');
process.exit(1);
function toBaselineRecord(violation) {
const missing = Array.isArray(violation.details?.missing)
? violation.details.missing.map(String).sort()
: [];
return {
command: String(violation.command ?? ''),
file: String(violation.file ?? ''),
missing,
};
}
function signature(record) {
return `${record.command}\0${record.file}\0${record.missing.join('\0')}`;
}
function sortRecords(records) {
return records
.map((record) => ({
command: String(record.command),
file: String(record.file),
missing: Array.isArray(record.missing) ? record.missing.map(String).sort() : [],
}))
.sort((a, b) => signature(a).localeCompare(signature(b)));
}
function relative(file) {
return file.replace(`${PROJECT_ROOT}/`, '');
}
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env node
/**
* check-typed-error-lint.mjs — CI gate for newly introduced silent failures.
*
* Baseline mode keeps adoption small: existing findings are recorded in
* scripts/typed-error-lint-baseline.json, while CI rejects any new
* silent-clamp / silent-empty-fallback / silent-sentinel signatures.
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '..');
const DIST_AUDIT = resolve(PROJECT_ROOT, 'dist', 'src', 'convention-audit.js');
const BASELINE_PATH = resolve(__dirname, 'typed-error-lint-baseline.json');
const UPDATE = process.argv.includes('--update-baseline');
const RULES = new Set(['silent-clamp', 'silent-empty-fallback', 'silent-sentinel']);
if (!existsSync(DIST_AUDIT)) {
console.error('dist/src/convention-audit.js not found. Run npm run build before this check.');
process.exit(1);
}
const { runConventionAudit } = await import(pathToFileURL(DIST_AUDIT).href);
const report = runConventionAudit({ projectRoot: PROJECT_ROOT });
const current = addOccurrenceIndexes(sortRecords(report.categories
.filter((category) => RULES.has(category.rule))
.flatMap((category) => category.violations.map(toBaselineRecord))));
if (UPDATE) {
writeFileSync(BASELINE_PATH, `${JSON.stringify(current, null, 2)}\n`);
console.log(`Updated ${relative(BASELINE_PATH)} with ${current.length} typed-error lint baseline entr${current.length === 1 ? 'y' : 'ies'}.`);
process.exit(0);
}
if (!existsSync(BASELINE_PATH)) {
console.error(`${relative(BASELINE_PATH)} not found. Run node scripts/check-typed-error-lint.mjs --update-baseline.`);
process.exit(1);
}
const baseline = addOccurrenceIndexes(sortRecords(JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'))));
const baselineSignatures = new Set(baseline.map(signature));
const currentSignatures = new Set(current.map(signature));
const added = current.filter((record) => !baselineSignatures.has(signature(record)));
const resolved = baseline.filter((record) => !currentSignatures.has(signature(record)));
console.log(`Typed-error lint gate: current=${current.length}, baseline=${baseline.length}, new=${added.length}, resolved=${resolved.length}`);
if (resolved.length > 0) {
console.log('');
console.log('Resolved baseline entries detected. Consider shrinking the baseline:');
for (const record of resolved) {
console.log(` - ${record.rule} ${record.command} ${record.file}:${record.line}`);
}
}
if (added.length === 0) {
console.log('OK - no new typed-error lint violations.');
process.exit(0);
}
console.log('');
console.log('New typed-error lint violations:');
for (const record of added) {
console.log(` - ${record.rule} ${record.command} ${record.file}:${record.line}`);
if (record.text) console.log(` ${record.text}`);
}
console.log('');
console.log('Fix the silent fallback, or if this is an intentional baseline adoption, run:');
console.log(' node scripts/check-typed-error-lint.mjs --update-baseline');
process.exit(1);
function toBaselineRecord(violation) {
return {
rule: String(violation.rule ?? ''),
command: String(violation.command ?? ''),
file: String(violation.file ?? ''),
line: Number(violation.line ?? 0),
text: String(violation.details?.text ?? ''),
};
}
function signature(record) {
return `${record.rule}\0${record.command}\0${record.file}\0${record.text}\0${record.occurrence}`;
}
function sortRecords(records) {
return records
.map((record) => ({
rule: String(record.rule),
command: String(record.command),
file: String(record.file),
line: Number(record.line ?? 0),
text: String(record.text ?? ''),
occurrence: Number(record.occurrence ?? 0),
}))
.sort((a, b) => stableOrder(a).localeCompare(stableOrder(b)));
}
function addOccurrenceIndexes(records) {
const seen = new Map();
return records.map((record) => {
const key = `${record.rule}\0${record.command}\0${record.file}\0${record.text}`;
const occurrence = seen.get(key) ?? 0;
seen.set(key, occurrence + 1);
return { ...record, occurrence };
});
}
function stableOrder(record) {
return `${record.rule}\0${record.command}\0${record.file}\0${record.text}\0${record.line}`;
}
function relative(file) {
return file.replace(`${PROJECT_ROOT}/`, '');
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Remove dist/ before a fresh build so deleted source modules do not leave
* stale compiled files behind.
*/
const { existsSync, rmSync } = require('fs');
if (existsSync('dist')) {
rmSync('dist', { recursive: true, force: true });
}
if (existsSync('tsconfig.tsbuildinfo')) {
rmSync('tsconfig.tsbuildinfo', { force: true });
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Copy YAML support files to dist/.
* (Adapters are JS-first and no longer need yaml copying.)
*/
const { copyFileSync, mkdirSync, existsSync } = require('fs');
// Copy external CLI registry to dist/
const extSrc = 'src/external-clis.yaml';
if (existsSync(extSrc)) {
mkdirSync('dist/src', { recursive: true });
copyFileSync(extSrc, 'dist/src/external-clis.yaml');
}
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env node
/**
* Sparse adapter sync: keeps ~/.opencli/clis/ clean by removing stale overrides.
*
* Strategy (hash-based, site-level granularity):
* - When an official site has upstream changes: DELETE the local override
* (do NOT copy new version — runtime falls back to package baseline)
* - When an official site has no changes: leave local override intact
* - User-created custom sites (not in package): always preserved
* - Skips entirely if already synced at the same version
*
* ~/.opencli/clis/ is a sparse override layer, not a full copy.
* Only eject-ed or user-modified sites appear here.
*
* Only runs on global install (npm install -g) or explicit OPENCLI_FETCH=1.
* No network calls — reads hashes from clis/ in the installed package.
*
* This is an ESM script (package.json type: module). No TypeScript, no src/ imports.
*/
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, resolve, dirname, relative } from 'node:path';
import { homedir } from 'node:os';
const OPENCLI_DIR = join(homedir(), '.opencli');
const USER_CLIS_DIR = join(OPENCLI_DIR, 'clis');
const MANIFEST_PATH = join(OPENCLI_DIR, 'adapter-manifest.json');
const PACKAGE_ROOT = resolve(import.meta.dirname, '..');
const BUILTIN_CLIS = join(PACKAGE_ROOT, 'clis');
function log(msg) {
console.log(`[opencli] ${msg}`);
}
function getPackageVersion() {
try {
return JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf-8')).version;
} catch {
return 'unknown';
}
}
/**
* Compute SHA-256 hash of file content.
*/
function fileHash(filePath) {
return createHash('sha256').update(readFileSync(filePath)).digest('hex');
}
/**
* Read existing manifest. Returns { version, files, hashes } or null.
*/
function readManifest() {
try {
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'));
} catch {
return null;
}
}
/**
* Collect all relative file paths under a directory.
*/
function walkFiles(dir, prefix = '') {
const results = [];
if (!existsSync(dir)) return results;
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const rel = prefix ? `${prefix}/${entry}` : entry;
if (statSync(full).isDirectory()) {
results.push(...walkFiles(full, rel));
} else {
results.push(rel);
}
}
return results;
}
/**
* Remove empty parent directories up to (but not including) stopAt.
*/
function pruneEmptyDirs(filePath, stopAt) {
const boundary = resolve(stopAt);
let dir = resolve(dirname(filePath));
while (dir !== boundary) {
const rel = relative(boundary, dir);
if (!rel || rel.startsWith('..')) break;
try {
const entries = readdirSync(dir);
if (entries.length > 0) break;
rmSync(dir);
dir = dirname(dir);
} catch {
break;
}
}
}
export function fetchAdapters() {
const currentVersion = getPackageVersion();
const oldManifest = readManifest();
// Skip if already installed at the same version (unless forced via OPENCLI_FETCH=1)
const isForced = process.env.OPENCLI_FETCH === '1';
if (!isForced && currentVersion !== 'unknown' && oldManifest?.version === currentVersion) {
log(`Adapters already up to date (v${currentVersion})`);
return;
}
if (!existsSync(BUILTIN_CLIS)) {
log('Warning: clis/ not found in package — skipping adapter copy');
return;
}
const newOfficialFiles = new Set(walkFiles(BUILTIN_CLIS));
const oldOfficialFiles = new Set(oldManifest?.files ?? []);
const rawHashes = oldManifest?.hashes;
// Guard against corrupted manifest: if hashes is a non-object type (string, number,
// array), skip sync to avoid false-positive "changed" detection that deletes overrides.
// null/undefined are treated as empty (old manifests may lack the field).
if (rawHashes != null && (typeof rawHashes !== 'object' || Array.isArray(rawHashes))) {
log('Warning: adapter-manifest.json has corrupted hashes — skipping sync. Will fix on next run.');
return;
}
const oldHashes = rawHashes ?? {};
mkdirSync(USER_CLIS_DIR, { recursive: true });
// 1. Compute new hashes and detect which sites have changes
const newHashes = {};
const siteFiles = new Map(); // site -> [relPath, ...]
for (const relPath of newOfficialFiles) {
const src = join(BUILTIN_CLIS, relPath);
const srcHash = fileHash(src);
newHashes[relPath] = srcHash;
const site = relPath.split('/')[0];
if (!siteFiles.has(site)) siteFiles.set(site, []);
siteFiles.get(site).push(relPath);
}
// Determine which sites have any changed/new/removed files
const changedSites = new Set();
for (const [site, files] of siteFiles) {
for (const relPath of files) {
if (oldHashes[relPath] !== newHashes[relPath]) {
changedSites.add(site);
break;
}
}
}
// Also mark sites that had files removed
for (const relPath of oldOfficialFiles) {
if (!newOfficialFiles.has(relPath)) {
changedSites.add(relPath.split('/')[0]);
}
}
// 2. Sparse cleanup: for changed/removed official sites, delete local overrides.
// Do NOT copy new versions — runtime falls back to package baseline.
// Only eject-ed sites live in ~/.opencli/clis/.
let cleared = 0;
for (const site of changedSites) {
const siteDir = join(USER_CLIS_DIR, site);
if (existsSync(siteDir)) {
rmSync(siteDir, { recursive: true, force: true });
cleared++;
}
}
// 3. Clean up stale .ts adapter files left by older versions (pre-1.7.1)
// Older versions shipped adapters as .ts; current versions use .js only.
let tsCleaned = 0;
for (const relPath of walkFiles(USER_CLIS_DIR)) {
if (relPath.endsWith('.ts') && !relPath.endsWith('.d.ts')) {
const jsCounterpart = relPath.replace(/\.ts$/, '.js');
if (newOfficialFiles.has(jsCounterpart)) {
try {
unlinkSync(join(USER_CLIS_DIR, relPath));
pruneEmptyDirs(join(USER_CLIS_DIR, relPath), USER_CLIS_DIR);
tsCleaned++;
} catch { /* ignore */ }
}
}
}
if (tsCleaned > 0) log(`Cleaned up ${tsCleaned} stale .ts adapter files`);
// 3b. Clean up stale .yaml/.yml adapter files left by older versions (pre-1.7.0)
// Older versions shipped adapters as YAML; current versions use .js only.
// These are no longer discoverable and can shadow the current .js adapter layout.
let yamlCleaned = 0;
for (const relPath of walkFiles(USER_CLIS_DIR)) {
if (relPath.endsWith('.yaml') || relPath.endsWith('.yml')) {
const jsCounterpart = relPath.replace(/\.ya?ml$/, '.js');
if (newOfficialFiles.has(jsCounterpart)) {
try {
unlinkSync(join(USER_CLIS_DIR, relPath));
pruneEmptyDirs(join(USER_CLIS_DIR, relPath), USER_CLIS_DIR);
yamlCleaned++;
} catch { /* ignore */ }
}
}
}
if (yamlCleaned > 0) log(`Cleaned up ${yamlCleaned} stale .yaml adapter files`);
// 4. Clean up legacy compat shim files from ~/.opencli/
// These were created by an older approach that placed re-export shims directly
// in ~/.opencli/ (e.g., registry.js, errors.js, browser/). The current approach
// uses a node_modules/@jackwener/opencli symlink instead.
const LEGACY_SHIM_FILES = [
'registry.js', 'errors.js', 'utils.js', 'launcher.js', 'logger.js', 'types.js',
];
const LEGACY_SHIM_DIRS = [
'browser', 'download', 'errors', 'launcher', 'logger', 'pipeline', 'registry', 'types', 'utils',
];
let legacyCleaned = 0;
for (const file of LEGACY_SHIM_FILES) {
const p = join(OPENCLI_DIR, file);
try {
const content = readFileSync(p, 'utf-8');
// Only delete if it's a re-export shim, not a user-created file
if (content.includes("export * from 'file://")) {
unlinkSync(p);
legacyCleaned++;
}
} catch { /* doesn't exist */ }
}
for (const dir of LEGACY_SHIM_DIRS) {
const p = join(OPENCLI_DIR, dir);
try {
// Delete individual shim files, then prune empty directory
for (const entry of readdirSync(p)) {
const fp = join(p, entry);
try {
if (!statSync(fp).isFile()) continue;
const content = readFileSync(fp, 'utf-8');
if (content.includes("export * from 'file://")) {
unlinkSync(fp);
legacyCleaned++;
}
} catch { /* skip unreadable entries */ }
}
// Remove directory only if now empty
try {
if (readdirSync(p).length === 0) rmSync(p);
} catch { /* ignore */ }
} catch { /* doesn't exist or not a directory */ }
}
// 5. Clean up stale .plugins.lock.json.tmp-* files
let tmpCleaned = 0;
try {
for (const entry of readdirSync(OPENCLI_DIR)) {
if (entry.startsWith('.plugins.lock.json.tmp-')) {
try {
unlinkSync(join(OPENCLI_DIR, entry));
tmpCleaned++;
} catch { /* ignore */ }
}
}
} catch { /* ignore */ }
if (legacyCleaned > 0 || tmpCleaned > 0) {
log(`Cleaned up${legacyCleaned > 0 ? ` ${legacyCleaned} legacy shim files` : ''}${tmpCleaned > 0 ? `${legacyCleaned > 0 ? ',' : ''} ${tmpCleaned} stale tmp files` : ''}`);
}
// 6. Write updated manifest (with per-file hashes for smart sync)
writeFileSync(MANIFEST_PATH, JSON.stringify({
version: currentVersion,
files: [...newOfficialFiles].sort(),
hashes: newHashes,
updatedAt: new Date().toISOString(),
}, null, 2));
log(`Synced adapters: ${cleared} local override(s) cleared` +
(tsCleaned > 0 ? `, ${tsCleaned} stale .ts files removed` : '') +
(yamlCleaned > 0 ? `, ${yamlCleaned} stale .yaml files removed` : ''));
}
function main() {
// Skip in CI
if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) return;
// Only run on global install, explicit trigger, or first-run fallback
const isGlobal = process.env.npm_config_global === 'true';
const isExplicit = process.env.OPENCLI_FETCH === '1';
const isFirstRun = process.env._OPENCLI_FIRST_RUN === '1';
if (!isGlobal && !isExplicit && !isFirstRun) return;
fetchAdapters();
}
main();
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env node
/**
* postinstall script — install shell completion files and print setup instructions.
*
* Detects the user's default shell and writes the completion script to the
* standard completion directory. For zsh and bash, the script prints manual
* instructions instead of modifying rc files (~/.zshrc, ~/.bashrc) — this
* avoids breaking multi-line shell commands and other fragile rc structures.
* Fish completions work automatically without rc changes.
*
* Supported shells: bash, zsh, fish.
*
* This script is intentionally plain Node.js (no TypeScript, no imports from
* the main source tree) so that it can run without a build step.
*/
import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
// ── Completion script content ──────────────────────────────────────────────
const BASH_COMPLETION = `# Bash completion for opencli (auto-installed)
_opencli_completions() {
local cur words cword
_get_comp_words_by_ref -n : cur words cword
local completions
completions=$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)
COMPREPLY=( $(compgen -W "$completions" -- "$cur") )
__ltrim_colon_completions "$cur"
}
complete -F _opencli_completions opencli
`;
const ZSH_COMPLETION = `#compdef opencli
# Zsh completion for opencli (auto-installed)
_opencli() {
local -a completions
local cword=$((CURRENT - 1))
completions=(\${(f)"$(opencli --get-completions --cursor "$cword" "\${words[@]:1}" 2>/dev/null)"})
compadd -a completions
}
_opencli
`;
const FISH_COMPLETION = `# Fish completion for opencli (auto-installed)
complete -c opencli -f -a '(
set -l tokens (commandline -cop)
set -l cursor (count (commandline -cop))
opencli --get-completions --cursor $cursor $tokens[2..] 2>/dev/null
)'
`;
// ── Helpers ────────────────────────────────────────────────────────────────
function detectShell() {
const shell = process.env.SHELL || '';
if (shell.includes('zsh')) return 'zsh';
if (shell.includes('bash')) return 'bash';
if (shell.includes('fish')) return 'fish';
return null;
}
function ensureDir(dir) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
// ── Main ───────────────────────────────────────────────────────────────────
function main() {
// Skip in CI environments
if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) {
return;
}
// Only install completion for global installs and npm link
const isGlobal = process.env.npm_config_global === 'true';
if (!isGlobal) {
return;
}
const shell = detectShell();
if (!shell) {
// Cannot determine shell; silently skip
return;
}
const home = homedir();
try {
switch (shell) {
case 'zsh': {
const completionsDir = join(home, '.zsh', 'completions');
const completionFile = join(completionsDir, '_opencli');
ensureDir(completionsDir);
writeFileSync(completionFile, ZSH_COMPLETION, 'utf8');
console.log(`✓ Zsh completion installed to ${completionFile}`);
console.log('');
console.log(' \x1b[1mTo enable, add these lines to your ~/.zshrc:\x1b[0m');
console.log(` fpath=(${completionsDir} $fpath)`);
console.log(' autoload -Uz compinit && compinit');
console.log('');
console.log(' If you already have compinit (oh-my-zsh, zinit, etc.), just add the fpath line \x1b[1mbefore\x1b[0m it.');
console.log(' Then restart your shell or run: \x1b[36mexec zsh\x1b[0m');
break;
}
case 'bash': {
const userCompDir = join(home, '.bash_completion.d');
const completionFile = join(userCompDir, 'opencli');
ensureDir(userCompDir);
writeFileSync(completionFile, BASH_COMPLETION, 'utf8');
console.log(`✓ Bash completion installed to ${completionFile}`);
console.log('');
console.log(' \x1b[1mTo enable, add this line to your ~/.bashrc:\x1b[0m');
console.log(` [ -f "${completionFile}" ] && source "${completionFile}"`);
console.log('');
console.log(' Then restart your shell or run: \x1b[36msource ~/.bashrc\x1b[0m');
break;
}
case 'fish': {
const completionsDir = join(home, '.config', 'fish', 'completions');
const completionFile = join(completionsDir, 'opencli.fish');
ensureDir(completionsDir);
writeFileSync(completionFile, FISH_COMPLETION, 'utf8');
console.log(`✓ Fish completion installed to ${completionFile}`);
console.log(` Restart your shell to activate.`);
break;
}
}
} catch (err) {
// Completion install is best-effort; never fail the package install
if (process.env.OPENCLI_VERBOSE) {
console.error(`Warning: Could not install shell completion: ${err.message}`);
}
}
// ── Spotify credentials template ────────────────────────────────────
const opencliDir = join(home, '.opencli');
const spotifyEnvFile = join(opencliDir, 'spotify.env');
ensureDir(opencliDir);
if (!existsSync(spotifyEnvFile)) {
writeFileSync(spotifyEnvFile,
`# Spotify credentials — get them at https://developer.spotify.com/dashboard\n` +
`# Add http://127.0.0.1:8888/callback as a Redirect URI in your Spotify app\n` +
`SPOTIFY_CLIENT_ID=your_spotify_client_id_here\n` +
`SPOTIFY_CLIENT_SECRET=your_spotify_client_secret_here\n`,
'utf8'
);
console.log(`✓ Spotify credentials template created at ${spotifyEnvFile}`);
console.log(` Edit the file and add your Client ID and Secret, then run: opencli spotify auth`);
}
// ── Browser Bridge setup hint ───────────────────────────────────────
console.log('');
console.log(' \x1b[1mNext step — Browser Bridge setup\x1b[0m');
console.log(' Browser commands (bilibili, zhihu, twitter...) require the extension:');
console.log(' 1. Download: https://github.com/jackwener/opencli/releases');
console.log(' 2. In Chrome or Chromium, open chrome://extensions → enable Developer Mode → Load unpacked');
console.log('');
console.log(' Then run \x1b[36mopencli doctor\x1b[0m to verify.');
console.log('');
}
main();
+910
View File
@@ -0,0 +1,910 @@
[
{
"command": "1688/assets",
"file": "clis/1688/assets.js",
"missing": [
"detail_images",
"item_url",
"main_images",
"other_images",
"raw_assets",
"sku_images",
"source",
"videos"
]
},
{
"command": "1688/assets",
"file": "clis/1688/assets.js",
"missing": [
"gallery",
"href",
"offerId",
"offerTitle",
"scannedAssets"
]
},
{
"command": "1688/download",
"file": "clis/1688/download.js",
"missing": [
"filename",
"url"
]
},
{
"command": "1688/item",
"file": "clis/1688/item.js",
"missing": [
"bodyText",
"gallery",
"href",
"offerId",
"offerTitle",
"seller",
"services",
"shipping",
"trade"
]
},
{
"command": "1688/item",
"file": "clis/1688/item.js",
"missing": [
"currency",
"customization_text",
"delivery_days_text",
"item_url",
"main_images",
"member_id",
"moq_value",
"price_tiers",
"private_label_text",
"sales_text",
"seller_url",
"service_badges",
"shop_id",
"shop_name",
"stock_quantity",
"visible_attributes"
]
},
{
"command": "1688/search",
"file": "clis/1688/search.js",
"missing": [
"badges",
"currency",
"fetched_at",
"moq_value",
"price_max",
"price_min",
"return_rate_text",
"sales_text",
"seller_url",
"shop_id",
"source_url",
"strategy"
]
},
{
"command": "1688/search",
"file": "clis/1688/search.js",
"missing": [
"bodyText",
"candidates",
"href",
"next_url"
]
},
{
"command": "1688/search",
"file": "clis/1688/search.js",
"missing": [
"container_text",
"desc_rows",
"hover_items",
"hover_price_text",
"sales_text",
"seller_url",
"tag_items"
]
},
{
"command": "1688/store",
"file": "clis/1688/store.js",
"missing": [
"business_model_text",
"company_name",
"company_url",
"factory_badges",
"member_id",
"mobile_text",
"phone_text",
"response_rate_text",
"service_badges",
"shop_id",
"staff_size_text",
"store_url",
"top_categories"
]
},
{
"command": "51job/company",
"file": "clis/51job/company.js",
"missing": [
"cInfoParts",
"links",
"sidebarText"
]
},
{
"command": "51job/detail",
"file": "clis/51job/detail.js",
"missing": [
"companyTag",
"finalUrl",
"meta"
]
},
{
"command": "amazon/discussion",
"file": "clis/amazon/discussion.js",
"missing": [
"average_rating_text",
"discussion_url",
"product_url",
"qa_urls",
"review_samples",
"total_review_count_text"
]
},
{
"command": "amazon/offer",
"file": "clis/amazon/offer.js",
"missing": [
"buybox_text",
"href",
"merchant_info",
"offer_link",
"qa_url",
"review_url",
"ships_from_text",
"title"
]
},
{
"command": "amazon/offer",
"file": "clis/amazon/offer.js",
"missing": [
"currency",
"merchant_info_text",
"offer_listing_url",
"price_value",
"product_url",
"qa_url",
"review_url"
]
},
{
"command": "amazon/product",
"file": "clis/amazon/product.js",
"missing": [
"brand_text",
"breadcrumbs",
"bullet_points",
"currency",
"price_value",
"product_url",
"qa_url",
"rating_text",
"review_count_text",
"review_url"
]
},
{
"command": "amazon/product",
"file": "clis/amazon/product.js",
"missing": [
"breadcrumbs",
"bullets",
"byline",
"href",
"product_title",
"qa_url",
"rating_text",
"review_count_text",
"review_url"
]
},
{
"command": "amazon/search",
"file": "clis/amazon/search.js",
"missing": [
"badge_texts",
"href",
"rating_text",
"review_count_text",
"sponsored"
]
},
{
"command": "amazon/search",
"file": "clis/amazon/search.js",
"missing": [
"badges",
"currency",
"is_sponsored",
"price_value",
"product_url",
"rating_text",
"review_count_text"
]
},
{
"command": "band/post",
"file": "clis/band/post.js",
"missing": [
"comments",
"photos"
]
},
{
"command": "band/post",
"file": "clis/band/post.js",
"missing": [
"depth"
]
},
{
"command": "band/post",
"file": "clis/band/post.js",
"missing": [
"filename",
"url"
]
},
{
"command": "bilibili/download",
"file": "clis/bilibili/download.js",
"missing": [
"author"
]
},
{
"command": "bilibili/feed",
"file": "clis/bilibili/feed.js",
"missing": [
"comments",
"itemType"
]
},
{
"command": "binance/depth",
"file": "clis/binance/depth.js",
"missing": [
"select"
]
},
{
"command": "binance/gainers",
"file": "clis/binance/gainers.js",
"missing": [
"sort_change"
]
},
{
"command": "binance/losers",
"file": "clis/binance/losers.js",
"missing": [
"sort_change"
]
},
{
"command": "binance/ticker",
"file": "clis/binance/ticker.js",
"missing": [
"sort_volume"
]
},
{
"command": "binance/top",
"file": "clis/binance/top.js",
"missing": [
"sort_volume"
]
},
{
"command": "bloomberg/news",
"file": "clis/bloomberg/news.js",
"missing": [
"errorCode",
"message",
"preview"
]
},
{
"command": "bloomberg/news",
"file": "clis/bloomberg/news.js",
"missing": [
"errorCode",
"preview"
]
},
{
"command": "boss/resume",
"file": "clis/boss/resume.js",
"missing": [
"activeTime",
"jobChatting",
"workHistory"
]
},
{
"command": "coupang/search",
"file": "clis/coupang/search.js",
"missing": [
"badge",
"category",
"deliveryPromise",
"deliveryType",
"discountRate",
"originalPrice",
"productId",
"reviewCount",
"seller",
"unitPrice"
]
},
{
"command": "coupang/search",
"file": "clis/coupang/search.js",
"missing": [
"originalPrice",
"unitPrice"
]
},
{
"command": "douban/download",
"file": "clis/douban/download.js",
"missing": [
"detail_url",
"image_url",
"photo_id"
]
},
{
"command": "douban/marks",
"file": "clis/douban/marks.js",
"missing": [
"movieId"
]
},
{
"command": "douban/photos",
"file": "clis/douban/photos.js",
"missing": [
"page",
"subject_title",
"thumb_url",
"type"
]
},
{
"command": "douban/reviews",
"file": "clis/douban/reviews.js",
"missing": [
"createdAt",
"movieId",
"reviewId"
]
},
{
"command": "hupu/mentions",
"file": "clis/hupu/mentions.js",
"missing": [
"has_next_page",
"msg_type",
"next_page_str",
"topic_id"
]
},
{
"command": "indeed/job",
"file": "clis/indeed/job.js",
"missing": [
"challenge",
"jobType",
"notFound",
"ready"
]
},
{
"command": "indeed/search",
"file": "clis/indeed/search.js",
"missing": [
"jk"
]
},
{
"command": "instagram/note",
"file": "clis/instagram/note.js",
"missing": [
"stage",
"text"
]
},
{
"command": "instagram/post",
"file": "clis/instagram/post.js",
"missing": [
"failed",
"settled"
]
},
{
"command": "instagram/post",
"file": "clis/instagram/post.js",
"missing": [
"state"
]
},
{
"command": "instagram/reel",
"file": "clis/instagram/reel.js",
"missing": [
"failed",
"settled"
]
},
{
"command": "instagram/reel",
"file": "clis/instagram/reel.js",
"missing": [
"state"
]
},
{
"command": "jd/item",
"file": "clis/jd/item.js",
"missing": [
"hasProductMarker",
"hasSecurityChallenge",
"href",
"isLoginPage",
"isProductPage",
"looksBlocked",
"onExpectedItemUrl"
]
},
{
"command": "jianyu/search",
"file": "clis/jianyu/search.js",
"missing": [
"contextText",
"date"
]
},
{
"command": "jianyu/search",
"file": "clis/jianyu/search.js",
"missing": [
"detail_reason"
]
},
{
"command": "jianyu/search",
"file": "clis/jianyu/search.js",
"missing": [
"detail_reason",
"notice_id",
"source_id"
]
},
{
"command": "jike/notifications",
"file": "clis/jike/notifications.js",
"missing": [
"actionType",
"fromUser"
]
},
{
"command": "jike/topic",
"file": "clis/jike/topic.js",
"missing": [
"id"
]
},
{
"command": "ke/chengjiao",
"file": "clis/ke/chengjiao.js",
"missing": [
"url"
]
},
{
"command": "ke/xiaoqu",
"file": "clis/ke/xiaoqu.js",
"missing": [
"url"
]
},
{
"command": "linkedin/timeline",
"file": "clis/linkedin/timeline.js",
"missing": [
"authorUrl",
"postedAt"
]
},
{
"command": "linkedin/timeline",
"file": "clis/linkedin/timeline.js",
"missing": [
"id"
]
},
{
"command": "linkedin/timeline",
"file": "clis/linkedin/timeline.js",
"missing": [
"postedAt"
]
},
{
"command": "linux-do/tags",
"file": "clis/linux-do/tags.js",
"missing": [
"id"
]
},
{
"command": "paperreview/review",
"file": "clis/paperreview/review.js",
"missing": [
"message",
"token"
]
},
{
"command": "powerchina/search",
"file": "clis/powerchina/search.js",
"missing": [
"contextText",
"date"
]
},
{
"command": "producthunt/hot",
"file": "clis/producthunt/hot.js",
"missing": [
"voteCandidates"
]
},
{
"command": "tieba/search",
"file": "clis/tieba/search.js",
"missing": [
"snippet"
]
},
{
"command": "twitter/accept",
"file": "clis/twitter/accept.js",
"missing": [
"href",
"idx",
"text"
]
},
{
"command": "twitter/bookmarks",
"file": "clis/twitter/bookmarks.js",
"missing": [
"name"
]
},
{
"command": "twitter/list-remove",
"file": "clis/twitter/list-remove.js",
"missing": [
"method",
"ts",
"url"
]
},
{
"command": "twitter/list-tweets",
"file": "clis/twitter/list-tweets.js",
"missing": [
"name"
]
},
{
"command": "twitter/reply-dm",
"file": "clis/twitter/reply-dm.js",
"missing": [
"convId",
"href",
"idx",
"preview"
]
},
{
"command": "twitter/thread",
"file": "clis/twitter/thread.js",
"missing": [
"created_at",
"in_reply_to"
]
},
{
"command": "twitter/tweets",
"file": "clis/twitter/tweets.js",
"missing": [
"name"
]
},
{
"command": "uiverse/code",
"file": "clis/uiverse/code.js",
"missing": [
"code",
"isTailwind",
"postId",
"type",
"url"
]
},
{
"command": "uiverse/preview",
"file": "clis/uiverse/preview.js",
"missing": [
"matchedClassName",
"matchedTag",
"postId",
"selectorSource",
"url",
"x",
"y"
]
},
{
"command": "v2ex/daily",
"file": "clis/v2ex/daily.js",
"missing": [
"claimed"
]
},
{
"command": "v2ex/daily",
"file": "clis/v2ex/daily.js",
"missing": [
"claimed",
"once"
]
},
{
"command": "web/read",
"file": "clis/web/read.js",
"missing": [
"accessible",
"index",
"sameOrigin",
"src",
"textLength"
]
},
{
"command": "web/read",
"file": "clis/web/read.js",
"missing": [
"bodyTruncated",
"contentType",
"method",
"url"
]
},
{
"command": "weibo/me",
"file": "clis/weibo/me.js",
"missing": [
"avatar",
"description",
"profile_url"
]
},
{
"command": "weibo/user",
"file": "clis/weibo/user.js",
"missing": [
"avatar",
"birthday",
"created_at",
"gender",
"ip_location",
"verified_reason"
]
},
{
"command": "weread/book",
"file": "clis/weread/book.js",
"missing": [
"metadataReady"
]
},
{
"command": "weread/book",
"file": "clis/weread/book.js",
"missing": [
"url"
]
},
{
"command": "xianyu/item",
"file": "clis/xianyu/item.js",
"missing": [
"browse_count",
"category",
"collect_count",
"description",
"image_count",
"image_urls",
"item_url",
"original_price",
"reply_interval",
"reply_ratio_24h",
"seller_id",
"seller_score",
"seller_url",
"status"
]
},
{
"command": "xianyu/search",
"file": "clis/xianyu/search.js",
"missing": [
"extra",
"original_price"
]
},
{
"command": "xiaohongshu/creator-note-detail",
"file": "clis/xiaohongshu/creator-note-detail.js",
"missing": [
"label"
]
},
{
"command": "xiaohongshu/creator-notes-summary",
"file": "clis/xiaohongshu/creator-notes-summary.js",
"missing": [
"published_at",
"top_interest_pct",
"top_source_pct"
]
},
{
"command": "xiaohongshu/creator-notes",
"file": "clis/xiaohongshu/creator-notes-summary.js",
"missing": [
"avg_view_time",
"published_at",
"rise_fans",
"shares",
"top_interest",
"top_interest_pct",
"top_source",
"top_source_pct"
]
},
{
"command": "xiaohongshu/download",
"file": "clis/xiaohongshu/download.js",
"missing": [
"url"
]
},
{
"command": "xiaohongshu/search",
"file": "clis/xiaohongshu/search.js",
"missing": [
"author_url"
]
},
{
"command": "xueqiu/comments",
"file": "clis/xueqiu/comments.js",
"missing": [
"id"
]
},
{
"command": "xueqiu/earnings-date",
"file": "clis/xueqiu/earnings-date.js",
"missing": [
"_future",
"_ts"
]
},
{
"command": "xueqiu/hot-stock",
"file": "clis/xueqiu/hot-stock.js",
"missing": [
"url"
]
},
{
"command": "xueqiu/kline",
"file": "clis/xueqiu/kline.js",
"missing": [
"percent",
"symbol"
]
},
{
"command": "xueqiu/watchlist",
"file": "clis/xueqiu/watchlist.js",
"missing": [
"change",
"url",
"volume"
]
},
{
"command": "yahoo-finance/quote",
"file": "clis/yahoo-finance/quote.js",
"missing": [
"currency",
"exchange"
]
},
{
"command": "yollomi/upload",
"file": "clis/yollomi/upload.js",
"missing": [
"data"
]
},
{
"command": "youtube/playlist",
"file": "clis/youtube/playlist.js",
"missing": [
"channelName",
"stats",
"videos"
]
},
{
"command": "youtube/watch-later",
"file": "clis/youtube/watch-later.js",
"missing": [
"stats",
"videos"
]
},
{
"command": "zhihu/hot",
"file": "clis/zhihu/hot.js",
"missing": [
"answer_count",
"follower_count",
"url"
]
},
{
"command": "zhihu/hot",
"file": "clis/zhihu/hot.js",
"missing": [
"url"
]
},
{
"command": "zsxq/groups",
"file": "clis/zsxq/groups.js",
"missing": [
"valid_until"
]
}
]
File diff suppressed because it is too large Load Diff