Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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,
};