mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
2883620ba2
* feat: add gitee adapters and docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: compact gitee trending table output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add gitee adapter documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(gitee): avoid faking user index values --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: jackwener <jakevingoo@gmail.com>
252 lines
8.5 KiB
TypeScript
252 lines
8.5 KiB
TypeScript
import { CliError } from '@jackwener/opencli/errors';
|
||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||
|
||
interface DomUserSnapshot {
|
||
notFound: boolean;
|
||
blocked: boolean;
|
||
nickname: string;
|
||
followers: string;
|
||
publicRepos: string;
|
||
giteeIndex: string;
|
||
}
|
||
|
||
const GITEE_BASE_URL = 'https://gitee.com';
|
||
const GITEE_USER_API = 'https://gitee.com/api/v5/users';
|
||
|
||
function normalizeText(value: string): string {
|
||
return value.replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function sanitizeUsername(value: string): string {
|
||
return value.trim().replace(/^@+/, '').replace(/^\/+|\/+$/g, '');
|
||
}
|
||
|
||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||
return value as Record<string, unknown>;
|
||
}
|
||
|
||
function firstText(value: unknown): string {
|
||
if (typeof value === 'string') return normalizeText(value);
|
||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) {
|
||
const text = firstText(item);
|
||
if (text) return text;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function normalizeCount(value: unknown): string {
|
||
const raw = firstText(value);
|
||
if (!raw) return '';
|
||
const compact = raw.replace(/,/g, '');
|
||
const match = compact.match(/\d+(?:[.]\d+)?(?:[kKmMwW]|\u4E07)?/);
|
||
if (match) return match[0];
|
||
return '';
|
||
}
|
||
|
||
function pickFirst(...values: Array<string | undefined | null>): string {
|
||
for (const value of values) {
|
||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function apiGiteeIndex(user: Record<string, unknown> | null): string {
|
||
if (!user) return '';
|
||
const keys = [
|
||
'gitee_index',
|
||
'giteeIndex',
|
||
'index',
|
||
'score',
|
||
'contribution_score',
|
||
'contributionScore',
|
||
'contribution_index',
|
||
'contributionIndex',
|
||
] as const;
|
||
for (const key of keys) {
|
||
const value = normalizeCount(user[key]);
|
||
if (value) return value;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
cli({
|
||
site: 'gitee',
|
||
name: 'user',
|
||
description: 'Show a Gitee user profile panel',
|
||
domain: 'gitee.com',
|
||
strategy: Strategy.PUBLIC,
|
||
browser: true,
|
||
args: [
|
||
{ name: 'username', positional: true, required: true, help: 'Gitee username' },
|
||
],
|
||
columns: ['field', 'value'],
|
||
func: async (page, args) => {
|
||
const username = sanitizeUsername(String(args.username ?? ''));
|
||
if (!username) {
|
||
throw new CliError('INVALID_ARGUMENT', 'Username is required', 'Use: opencli gitee user <username>');
|
||
}
|
||
|
||
const profileUrl = `${GITEE_BASE_URL}/${encodeURIComponent(username)}`;
|
||
await page.goto(profileUrl);
|
||
await page.wait(2);
|
||
|
||
const rawDomSnapshot = await page.evaluate(`
|
||
(() => {
|
||
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||
const extractCount = (value) => {
|
||
const text = normalize(value).replace(/,/g, '');
|
||
if (!text) return '';
|
||
const match = text.match(/\\d+(?:[.]\\d+)?(?:\\s*[kKmMwW\\u4E07])?/);
|
||
return match ? match[0].replace(/\\s+/g, '') : '';
|
||
};
|
||
|
||
const title = normalize(document.title || '');
|
||
const bodyText = normalize(document.body?.innerText || '');
|
||
const notFound = /404|页面不存在|资源不存在|page not found/i.test(title + ' ' + bodyText);
|
||
const blocked = /访问受限|没有访问权限|forbidden|denied/i.test(bodyText);
|
||
|
||
const nicknameCandidates = Array.from(
|
||
document.querySelectorAll('.users__personal-name h2 span[title], .users__personal-name h2 [title], .users__personal-name h2 span, .users__personal-name h2, h1'),
|
||
);
|
||
const nicknameNode = nicknameCandidates.find((node) => {
|
||
const titleAttr = node && node.getAttribute ? node.getAttribute('title') : '';
|
||
const text = normalize((titleAttr || node.textContent || '').replace(/\\(\\s*备注名\\s*\\)/g, ''));
|
||
return !!text && !/备注名/.test(text);
|
||
}) || null;
|
||
const nicknameFromAttr = nicknameNode && nicknameNode.getAttribute ? nicknameNode.getAttribute('title') : '';
|
||
const nickname = normalize((nicknameFromAttr || nicknameNode?.textContent || '').replace(/\\(\\s*备注名\\s*\\)/g, ''));
|
||
|
||
let followers = extractCount(document.querySelector('#followers-number .social-count, #followers-number .follow-num')?.textContent || '');
|
||
if (!followers) {
|
||
const card = Array.from(document.querySelectorAll('.users__personal-socials .four.wide.column, .users__personal-socials [class*="column"]'))
|
||
.find((el) => /followers/i.test(normalize(el.textContent || '')));
|
||
if (card) followers = extractCount(card.textContent || '');
|
||
}
|
||
|
||
let publicRepos = '';
|
||
const projectLink = Array.from(document.querySelectorAll('a[href]'))
|
||
.find((el) => /\\/[^/?#]+\\/projects(?:$|[/?#])/i.test(el.getAttribute('href') || ''));
|
||
if (projectLink) publicRepos = extractCount(projectLink.textContent || '');
|
||
|
||
let giteeIndex = '';
|
||
const indexNodes = Array.from(
|
||
document.querySelectorAll('.users__personal-info *, .users__personal-container *, [class*="index" i], [id*="index" i], [class*="score" i], [id*="score" i]'),
|
||
);
|
||
for (const node of indexNodes) {
|
||
const text = normalize(node.textContent || '');
|
||
if (!/(码云指数|gitee\\s*index|gitee\\s*指数)/i.test(text)) continue;
|
||
|
||
const direct = text.match(/(?:码云指数|gitee\\s*index|gitee\\s*指数)[::]?\\s*(\\d+(?:[.]\\d+)?(?:\\s*[kKmMwW\\u4E07])?)/i);
|
||
if (direct?.[1]) {
|
||
giteeIndex = direct[1].replace(/\\s+/g, '');
|
||
break;
|
||
}
|
||
|
||
const siblingText = normalize(node.nextElementSibling?.textContent || '');
|
||
const parentText = normalize(node.parentElement?.textContent || '');
|
||
const around = extractCount(siblingText + ' ' + parentText);
|
||
if (around) {
|
||
giteeIndex = around;
|
||
break;
|
||
}
|
||
}
|
||
|
||
return {
|
||
notFound,
|
||
blocked,
|
||
nickname,
|
||
followers,
|
||
publicRepos,
|
||
giteeIndex,
|
||
};
|
||
})()
|
||
`) as unknown;
|
||
|
||
const domSnapshotRecord = asRecord(rawDomSnapshot);
|
||
const domSnapshot: DomUserSnapshot = {
|
||
notFound: domSnapshotRecord?.notFound === true,
|
||
blocked: domSnapshotRecord?.blocked === true,
|
||
nickname: firstText(domSnapshotRecord?.nickname),
|
||
followers: normalizeCount(domSnapshotRecord?.followers),
|
||
publicRepos: normalizeCount(domSnapshotRecord?.publicRepos),
|
||
giteeIndex: normalizeCount(domSnapshotRecord?.giteeIndex),
|
||
};
|
||
|
||
if (domSnapshot.notFound) {
|
||
throw new CliError(
|
||
'NOT_FOUND',
|
||
`Gitee user "${username}" does not exist`,
|
||
'Check the username and retry: opencli gitee user <username>',
|
||
);
|
||
}
|
||
|
||
if (domSnapshot.blocked) {
|
||
throw new CliError(
|
||
'FORBIDDEN',
|
||
`Gitee user page "${username}" is not accessible`,
|
||
'The profile may be private/restricted, or the account may be unavailable',
|
||
);
|
||
}
|
||
|
||
const apiUrl = `${GITEE_USER_API}/${encodeURIComponent(username)}`;
|
||
const apiResponse = await fetch(apiUrl, {
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'User-Agent': 'Mozilla/5.0',
|
||
Referer: profileUrl,
|
||
},
|
||
});
|
||
|
||
if (apiResponse.status === 404) {
|
||
throw new CliError(
|
||
'NOT_FOUND',
|
||
`Gitee user "${username}" does not exist`,
|
||
'Check the username and retry: opencli gitee user <username>',
|
||
);
|
||
}
|
||
|
||
if (!apiResponse.ok) {
|
||
throw new CliError(
|
||
'REQUEST_FAILED',
|
||
`Failed to read Gitee user profile API: ${apiResponse.status}`,
|
||
'Try again later or verify network access to gitee.com',
|
||
);
|
||
}
|
||
|
||
const apiUser = asRecord(await apiResponse.json() as unknown);
|
||
const nickname = pickFirst(
|
||
domSnapshot.nickname,
|
||
firstText(apiUser?.name),
|
||
firstText(apiUser?.login),
|
||
username,
|
||
);
|
||
const followers = pickFirst(
|
||
domSnapshot.followers,
|
||
normalizeCount(apiUser?.followers),
|
||
'-',
|
||
);
|
||
const publicRepos = pickFirst(
|
||
domSnapshot.publicRepos,
|
||
normalizeCount(apiUser?.public_repos),
|
||
'-',
|
||
);
|
||
const giteeIndex = pickFirst(
|
||
domSnapshot.giteeIndex,
|
||
apiGiteeIndex(apiUser),
|
||
'-',
|
||
);
|
||
|
||
return [
|
||
{ field: 'Nickname', value: nickname },
|
||
{ field: 'Followers', value: followers },
|
||
{ field: 'Public Repositories', value: publicRepos },
|
||
{ field: 'Gitee Index', value: giteeIndex },
|
||
{ field: 'URL', value: profileUrl },
|
||
];
|
||
},
|
||
});
|