9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
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
229 lines
7.2 KiB
JavaScript
229 lines
7.2 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import {
|
|
ArgumentError,
|
|
AuthRequiredError,
|
|
CommandExecutionError,
|
|
EmptyResultError,
|
|
} from '@jackwener/opencli/errors';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
const FLOMO_APP_DOMAIN = 'v.flomoapp.com';
|
|
const FLOMO_API_DOMAIN = 'flomoapp.com';
|
|
const MAX_LIMIT = 200;
|
|
|
|
function unwrapBrowserResult(value) {
|
|
if (value && typeof value === 'object' && 'session' in value && 'data' in value) {
|
|
return value.data;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parsePositiveIntArg(value, name, fallback, max) {
|
|
if (value === undefined || value === null || value === '') {
|
|
return fallback;
|
|
}
|
|
const text = String(value).trim();
|
|
if (!/^\d+$/.test(text)) {
|
|
throw new ArgumentError(`flomo memos --${name} must be a positive integer`);
|
|
}
|
|
const parsed = Number(text);
|
|
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > max) {
|
|
throw new ArgumentError(`flomo memos --${name} must be between 1 and ${max}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parseSinceArg(value) {
|
|
if (value === undefined || value === null || value === '') {
|
|
return 0;
|
|
}
|
|
const text = String(value).trim();
|
|
if (!/^\d+$/.test(text)) {
|
|
throw new ArgumentError('flomo memos --since must be a non-negative Unix timestamp in seconds');
|
|
}
|
|
const parsed = Number(text);
|
|
if (!Number.isSafeInteger(parsed)) {
|
|
throw new ArgumentError('flomo memos --since must be a safe integer Unix timestamp in seconds');
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parseSlugArg(value) {
|
|
if (value === undefined || value === null || value === '') {
|
|
return '';
|
|
}
|
|
const slug = String(value).trim();
|
|
if (!/^[A-Za-z0-9_-]{1,256}$/.test(slug)) {
|
|
throw new ArgumentError('flomo memos --slug must be an opaque memo cursor containing only letters, numbers, _ or -');
|
|
}
|
|
return slug;
|
|
}
|
|
|
|
function buildSignedUrl(limit, since, slug) {
|
|
const params = {
|
|
limit: String(limit),
|
|
latest_updated_at: String(since),
|
|
tz: '8:0',
|
|
timestamp: String(Math.floor(Date.now() / 1000)),
|
|
api_key: 'flomo_web',
|
|
app_version: '4.0',
|
|
platform: 'web',
|
|
webp: '1',
|
|
};
|
|
if (slug) params.latest_slug = slug;
|
|
const keys = Object.keys(params).sort();
|
|
const signBase = keys.map((key) => `${key}=${params[key]}`).join('&');
|
|
params.sign = createHash('md5').update(signBase + 'dbbc3dd73364b4084c3a69346e0ce2b2').digest('hex');
|
|
return 'https://flomoapp.com/api/v1/memo/updated/?' + new URLSearchParams(params).toString();
|
|
}
|
|
|
|
function buildGetTokenJs() {
|
|
return `
|
|
(() => {
|
|
try {
|
|
const raw = localStorage.getItem('me');
|
|
if (!raw) return null;
|
|
const me = JSON.parse(raw);
|
|
const token = me?.access_token || me?.data?.access_token || '';
|
|
return typeof token === 'string' && token.trim() ? token.trim() : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})()
|
|
`;
|
|
}
|
|
|
|
function isAuthFailureMessage(message) {
|
|
return /auth|unauth|login|token|permission|forbidden|unauthorized|登录|登陆|鉴权|权限/i.test(String(message || ''));
|
|
}
|
|
|
|
function normalizeTags(tags) {
|
|
if (!Array.isArray(tags)) return '';
|
|
return tags
|
|
.map((tag) => {
|
|
if (typeof tag === 'string') return tag;
|
|
return tag?.name || tag?.tag || tag?.content || '';
|
|
})
|
|
.map((tag) => String(tag).trim())
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
}
|
|
|
|
function normalizeImages(files) {
|
|
if (!Array.isArray(files)) return '';
|
|
return files
|
|
.map((file) => file?.thumbnail_url || file?.url || '')
|
|
.map((url) => String(url).trim())
|
|
.filter(Boolean)
|
|
.join(' | ');
|
|
}
|
|
|
|
function memoUrl(slug) {
|
|
return slug ? `https://${FLOMO_APP_DOMAIN}/mine/?memo_id=${encodeURIComponent(slug)}` : '';
|
|
}
|
|
|
|
function normalizeMemo(memo) {
|
|
if (!memo || typeof memo !== 'object' || Array.isArray(memo)) {
|
|
throw new CommandExecutionError('Flomo API returned a malformed memo entry');
|
|
}
|
|
const slug = String(memo.slug || memo.id || '').trim();
|
|
if (!slug) {
|
|
throw new CommandExecutionError('Flomo API returned a memo without slug/id');
|
|
}
|
|
return {
|
|
id: slug,
|
|
url: memoUrl(slug),
|
|
content: String(memo.content || '').trim(),
|
|
slug,
|
|
tags: normalizeTags(memo.tags),
|
|
images: normalizeImages(memo.files),
|
|
created_at: String(memo.created_at || ''),
|
|
updated_at: String(memo.updated_at || ''),
|
|
};
|
|
}
|
|
|
|
async function fetchFlomoJson(url, token) {
|
|
let resp;
|
|
try {
|
|
resp = await fetch(url, {
|
|
headers: {
|
|
Authorization: 'Bearer ' + token,
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
Accept: 'application/json',
|
|
},
|
|
});
|
|
} catch (err) {
|
|
throw new CommandExecutionError(`Failed to fetch Flomo memos: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
if (resp.status === 401 || resp.status === 403) {
|
|
throw new AuthRequiredError(FLOMO_API_DOMAIN, `Flomo API returned HTTP ${resp.status}; please refresh your Flomo login session`);
|
|
}
|
|
if (!resp.ok) {
|
|
throw new CommandExecutionError(`Flomo API returned HTTP ${resp.status}`);
|
|
}
|
|
try {
|
|
return await resp.json();
|
|
} catch (err) {
|
|
throw new CommandExecutionError(`Flomo API returned malformed JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
async function readAccessToken(page) {
|
|
const token = unwrapBrowserResult(await page.evaluate(buildGetTokenJs()));
|
|
if (typeof token !== 'string' || !token.trim()) {
|
|
throw new AuthRequiredError(FLOMO_API_DOMAIN, 'Flomo memos requires an active signed-in Flomo browser session');
|
|
}
|
|
return token.trim();
|
|
}
|
|
|
|
const command = cli({
|
|
site: 'flomo',
|
|
name: 'memos',
|
|
access: 'read',
|
|
description: 'List your Flomo memos',
|
|
domain: FLOMO_API_DOMAIN,
|
|
strategy: Strategy.COOKIE,
|
|
browser: true,
|
|
navigateBefore: `https://${FLOMO_APP_DOMAIN}/`,
|
|
args: [
|
|
{ name: 'limit', type: 'int', default: 20, help: 'Number of memos to fetch (1-200)' },
|
|
{ name: 'since', type: 'int', help: 'Only memos updated after this Unix timestamp in seconds' },
|
|
{ name: 'slug', help: 'Pagination cursor from a previous memo page' },
|
|
],
|
|
columns: ['id', 'url', 'content', 'slug', 'tags', 'images', 'created_at', 'updated_at'],
|
|
func: async (page, kwargs) => {
|
|
const limit = parsePositiveIntArg(kwargs.limit, 'limit', 20, MAX_LIMIT);
|
|
const since = parseSinceArg(kwargs.since);
|
|
const slug = parseSlugArg(kwargs.slug);
|
|
await page.wait(3).catch(() => {});
|
|
const token = await readAccessToken(page);
|
|
const body = await fetchFlomoJson(buildSignedUrl(limit, since, slug), token);
|
|
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
throw new CommandExecutionError('Flomo API returned a malformed response');
|
|
}
|
|
if (body.code !== 0) {
|
|
const message = body.message || `Flomo API error code ${body.code}`;
|
|
if (isAuthFailureMessage(message)) {
|
|
throw new AuthRequiredError(FLOMO_API_DOMAIN, message);
|
|
}
|
|
throw new CommandExecutionError(message);
|
|
}
|
|
if (!Array.isArray(body.data)) {
|
|
throw new CommandExecutionError('Flomo API returned malformed memo data');
|
|
}
|
|
if (body.data.length === 0) {
|
|
throw new EmptyResultError('flomo memos', 'No Flomo memos matched the requested filters.');
|
|
}
|
|
return body.data.map(normalizeMemo);
|
|
},
|
|
});
|
|
|
|
export const __test__ = {
|
|
buildSignedUrl,
|
|
command,
|
|
normalizeMemo,
|
|
parsePositiveIntArg,
|
|
parseSinceArg,
|
|
parseSlugArg,
|
|
};
|