Files
request-portal/tools/sp/sp.mjs
T
2026-08-21 09:34:28 +05:00

252 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* sp — тонкая обёртка над CLI for Microsoft 365 для РАЗБОРА, а не для правки данных.
*
* Зачем вообще: разбирая производительность портала, всё время нужно задать
* SharePoint один и тот же вопрос («а что реально отдаёт индекс?», «сколько
* партиций у типа?») и получить ответ текстом, который можно приложить к задаче.
* Через браузерную консоль это ручная работа с копипастой; здесь — команда.
*
* Почему device code, а не секрет приложения: пароль и секрет через этот
* инструмент не проходят вообще. `sp login` печатает код, пользователь вводит
* его в браузере сам, CLI дальше держит токен у себя. В репозитории не оседает
* ничего секретного.
*
* Почему сырой REST через токен, а не готовые команды `m365 spo`: группа
* `m365 search` — это Microsoft Search (Graph), совсем другой API. Нам нужен
* ИМЕННО SharePoint Search REST с SelectProperties — тот же, что дёргает портал
* (см. myRequests.service.ts). Тело запроса взято один в один из PnPjs
* (@pnp/sp/search/query.js): `{ request: { ...query } }`, массивы плоские,
* odata=nometadata. Иначе инструмент мерил бы не то, что портал.
*/
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url));
const CLI = join(HERE, 'node_modules', '@pnp', 'cli-microsoft365', 'dist', 'index.js');
// Значения по умолчанию — те же, что в src/webparts/requestPortal/shared/config.ts.
// Переопределяются переменными окружения, чтобы не править файл ради другого стенда.
const SITE = process.env.SP_SITE || 'https://tnsukz.sharepoint.com/sites/requests';
const APP_ID = process.env.SP_APP_ID || process.env.CLIMICROSOFT365_ENTRAAPPID || '';
const TENANT = process.env.SP_TENANT || process.env.CLIMICROSOFT365_TENANT || 'common';
/** Хост тенанта — именно он, а не адрес сайта, является ресурсом для токена. */
const RESOURCE = new URL(SITE).origin;
function m365(args, { inherit = false } = {}) {
return execFileSync(process.execPath, [CLI, ...args], {
encoding: 'utf8',
stdio: inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
maxBuffer: 64 * 1024 * 1024
});
}
function fail(message) {
console.error(`[sp] ${message}`);
process.exit(1);
}
let cachedToken;
function token() {
if (cachedToken) return cachedToken;
try {
cachedToken = m365(['util', 'accesstoken', 'get', '-r', RESOURCE, '-o', 'text']).trim();
} catch (e) {
fail(
`не удалось получить токен для ${RESOURCE}.\n` +
` Скорее всего нет входа — выполни: node sp.mjs login\n` +
` ${String(e.stderr || e.message).trim().split('\n')[0]}`
);
}
if (!cachedToken) fail(`пустой токен для ${RESOURCE} — выполни: node sp.mjs login`);
return cachedToken;
}
async function spFetch(path, init = {}) {
const url = path.startsWith('http') ? path : `${SITE}${path.startsWith('/') ? '' : '/'}${path}`;
const res = await fetch(url, {
...init,
headers: {
authorization: `Bearer ${token()}`,
accept: 'application/json;odata=nometadata',
...(init.body ? { 'content-type': 'application/json;odata=nometadata' } : {}),
...(init.headers || {})
}
});
const text = await res.text();
if (!res.ok) fail(`${res.status} ${res.statusText}\n${text.slice(0, 800)}`);
try {
return JSON.parse(text);
} catch {
return text;
}
}
// ---------------------------------------------------------------------------
// Команды
async function cmdLogin() {
if (!APP_ID) {
fail(
'не задан SP_APP_ID.\n' +
' CLI for Microsoft 365 с 7-й версии не возит собственное приложение —\n' +
' нужен свой Entra app (см. README.md, раздел «Разовая подготовка»).'
);
}
console.log(`[sp] вход в ${TENANT} приложением ${APP_ID}. Код появится ниже — введи его в браузере.`);
m365(['login', '--appId', APP_ID, '--tenant', TENANT, '--authType', 'deviceCode'], { inherit: true });
}
function cmdStatus() {
console.log(m365(['status', '-o', 'text']).trim());
console.log(`[sp] сайт: ${SITE}`);
console.log(`[sp] ресурс токена: ${RESOURCE}`);
}
function cmdLogout() {
m365(['logout'], { inherit: true });
}
/**
* SharePoint Search — тот же вызов, что делает портал.
*
* POST /_api/search/postquery, а не GET /query: KQL портала перечисляет через OR
* адреса ВСЕХ партиций, и в query-строке это упирается в предел длины URL.
*/
async function cmdSearch(argv) {
const kql = argv._[0];
if (!kql) fail('нужен KQL: node sp.mjs search "<запрос>" [--select a,b] [--rows N]');
const select = (argv.select || 'ListItemID,Path,Title').split(',').map((s) => s.trim()).filter(Boolean);
const rows = Number(argv.rows || 10);
const data = await spFetch('/_api/search/postquery', {
method: 'POST',
body: JSON.stringify({
request: { Querytext: kql, RowLimit: rows, SelectProperties: select, TrimDuplicates: false }
})
});
const relevant = data?.PrimaryQueryResult?.RelevantResults;
const rawRows = relevant?.Table?.Rows ?? [];
const parsed = rawRows.map((r) => {
const cells = r.Cells?.results ?? r.Cells ?? [];
return select.reduce((acc, key) => {
const cell = cells.filter((c) => c.Key === key)[0];
// Различаем «свойство не Retrievable» и «значение пустое» — ради этого
// различия инструмент и писался, схлопывать их в '' нельзя.
acc[key] = !cell ? '<нет в ответе>' : cell.Value === null ? '<null>' : cell.Value;
return acc;
}, {});
});
console.log(`[sp] в индексе ${relevant?.TotalRows ?? 0}, показано ${parsed.length}`);
console.log(JSON.stringify(parsed, null, 1));
}
/** Произвольный GET к REST — для всего, чего нет отдельной командой. */
async function cmdGet(argv) {
const path = argv._[0];
if (!path) fail('нужен путь: node sp.mjs get "/_api/web/lists?$select=Title"');
console.log(JSON.stringify(await spFetch(path), null, 1));
}
/**
* Все списки сайта с адресами — тем же запросом, которым портал резолвит
* списки согласования (см. shared/approval/approvalListLocator.ts). Заодно
* показывает, сколько на самом деле партиций: это и есть P из оценок.
*/
async function cmdLists() {
const data = await spFetch(
"/_api/web/lists?$select=Title,ItemCount,Hidden,RootFolder/ServerRelativeUrl&$expand=RootFolder&$top=5000"
);
const all = data.value ?? [];
const portal = all.filter((l) => !l.Hidden && /—\s*(данные|согласование)\s*$/.test(l.Title || ''));
portal
.sort((a, b) => String(a.Title).localeCompare(String(b.Title)))
.forEach((l) => {
console.log(`${String(l.ItemCount).padStart(6)} ${l.RootFolder?.ServerRelativeUrl} ${l.Title}`);
});
const data_ = portal.filter((l) => /данные\s*$/.test(l.Title)).length;
console.log(`[sp] всего списков на сайте ${all.length}; портальных ${portal.length} (данные: ${data_})`);
}
/**
* Отдаёт ли индекс поля заявки (Retrievable) — от этого зависит, можно ли
* заменить REST-дочитывание (loadByIds) на SelectProperties самого Search.
*
* Queryable и Retrievable у managed property — РАЗНЫЕ флаги: то, по чему можно
* фильтровать, не обязано приходить в ответе. Отбираем по самому
* RequestStatusOWSCHCS, поэтому найденное гарантированно является заявками
* портала, а не случайными элементами сайта.
*/
async function cmdProps(argv) {
const props = (
argv.select ||
'ListItemID,Path,Title,Created,Author,AuthorOWSUSER,RequestStatusOWSCHCS,RequestUniqueCodeOWSTEXT,LastModifiedTime'
)
.split(',')
.map((s) => s.trim());
const statuses = ['На согласовании', 'Черновик', 'Согласовано', 'Возвращена на доработку'];
const kql = `Path:"${SITE}/*" AND (${statuses.map((s) => `RequestStatusOWSCHCS:"${s}"`).join(' OR ')})`;
await cmdSearch({ _: [kql], select: props.join(','), rows: argv.rows || 3 });
console.log(
'[sp] <нет в ответе> или <null> у свойства = оно НЕ Retrievable, для этого поля REST остаётся.'
);
}
// ---------------------------------------------------------------------------
function parseArgv(args) {
const out = { _: [] };
for (let i = 0; i < args.length; i += 1) {
const a = args[i];
if (a.startsWith('--')) {
const [key, inline] = a.slice(2).split('=');
out[key] = inline ?? (args[i + 1] && !args[i + 1].startsWith('--') ? args[(i += 1)] : true);
} else {
out._.push(a);
}
}
return out;
}
const USAGE = `sp — запросы к SharePoint портала заявок (только чтение)
node sp.mjs login вход по device code (код вводишь в браузере сам)
node sp.mjs status кто залогинен и куда смотрим
node sp.mjs logout
node sp.mjs search "<KQL>" [--select a,b,c] [--rows N]
node sp.mjs props [--select a,b,c] [--rows N] отдаёт ли индекс поля заявки
node sp.mjs lists списки сайта и их размеры (партиции)
node sp.mjs get "/_api/web/..." произвольный GET
Окружение: SP_SITE, SP_APP_ID, SP_TENANT (см. README.md)`;
const [, , command, ...rest] = process.argv;
const argv = parseArgv(rest);
const commands = {
login: cmdLogin,
status: cmdStatus,
logout: cmdLogout,
search: cmdSearch,
props: cmdProps,
lists: cmdLists,
get: cmdGet
};
if (!command || !commands[command]) {
console.log(USAGE);
process.exit(command ? 1 : 0);
}
Promise.resolve(commands[command](argv)).catch((e) => fail(String(e.stack || e.message)));