#!/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 ? '' : 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] <нет в ответе> или у свойства = оно НЕ 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 "" [--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)));