
Qweather
- 2 installs
- 10 repo stars
- Updated July 30, 2026
- deusyu/rainman-skills
Queries real-time weather, forecasts, and life indices via the QWeather API, with city lookup to resolve LocationIDs.
About
Retrieves current weather, forecasts, and life indices using the QWeather API. A developer or user uses it to check weather by city name after resolving its LocationID.
- Real-time weather, forecasts, and life indices
- Requires QWEATHER_API_KEY; city lookup resolves LocationID
Qweather by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,839 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/deusyu/rainman-skills --skill qweatherAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 10 |
| Last updated | July 30, 2026 |
| Repository | deusyu/rainman-skills ↗ |
What it does
Queries real-time weather, forecasts, and life indices via the QWeather API, with city lookup to resolve LocationIDs.
Files
qweather — Weather Query Skill
Query real-time weather, forecasts, and life indices via QWeather (和风天气) API.
Quick Start
1. Ensure QWEATHER_API_KEY and QWEATHER_API_HOST are set (get both at https://console.qweather.com). 2. Run bun scripts/weather.ts --help in this skill directory. 3. Pick the matching command from references/command-map.md.
Workflow
1. If the user provides a city name (e.g. "北京", "Shanghai"), first run lookup to get the LocationID. 2. Use the LocationID to call now, forecast, or indices. 3. Return the result in natural language.
Common City Mapping
When the user provides a city name, use the lookup command to resolve it to a LocationID. Common examples:
- 北京 → 101010100
- 上海 → 101020100
- 广州 → 101280101
- 深圳 → 101280601
Always use lookup for unfamiliar city names to get the correct LocationID.
Notes
- This skill is script-first and does not run an MCP server.
- Requires
QWEATHER_API_KEYandQWEATHER_API_HOSTenvironment variables. - API host is per-developer, find yours at https://console.qweather.com/setting
- Data updated every 10-20 minutes for real-time weather.
Command Map
| Command | Endpoint | Required Flags | Optional Flags |
|---|---|---|---|
lookup | GET /geo/v2/city/lookup | --location | --adm, --range |
now | GET /v7/weather/now | --location | --lang, --unit |
forecast | GET /v7/weather/{days}d | --location | --days, --lang, --unit |
indices | GET /v7/indices/1d | --location | --type |
Flag Details
| Flag | Format | Default | Example |
|---|---|---|---|
--location | City name, LocationID, or lon,lat | - | 北京, 101010100, 116.41,39.92 |
--adm | Administrative division filter | - | 北京, 广东 |
--range | ISO 3166 country code | - | cn, us |
--days | Forecast days: 3, 7 | 3 | 7 |
--lang | Language code | zh | en, zh |
--unit | m (metric) or i (imperial) | m | i |
--type | Life index type IDs, comma-separated | 0 (all) | 3,5 |
Life Index Types (China)
| ID | Name |
|---|---|
| 0 | 全部 |
| 1 | 运动指数 |
| 2 | 洗车指数 |
| 3 | 穿衣指数 |
| 4 | 钓鱼指数 |
| 5 | 紫外线指数 |
| 6 | 旅游指数 |
| 7 | 过敏指数 |
| 8 | 舒适度指数 |
| 9 | 感冒指数 |
| 10 | 空气污染扩散条件指数 |
| 11 | 空调开启指数 |
| 12 | 太阳镜指数 |
| 13 | 化妆指数 |
| 14 | 晾晒指数 |
| 15 | 交通指数 |
| 16 | 防晒指数 |
Exit Codes
0: success2: input or config error3: network / timeout / HTTP transport error4: API business error (code != "200")5: unexpected internal error
Configuration
QWEATHER_API_KEY: API key from consoleQWEATHER_API_HOST: Per-developer host (e.g.xxxxx.re.qweatherapi.com)- Auth: key passed as query parameter
key=
import {
CliError,
ExitCode,
getApiHost,
getApiKey,
isRecord,
} from "./config.ts";
import { getJsonWithRetry, type HttpGetRequest } from "./http.ts";
import type { CommandName } from "./validators.ts";
export interface ExecuteCommandDependencies {
requestJson?: (request: HttpGetRequest) => Promise<unknown>;
apiKey?: string;
apiHost?: string;
timeoutMs?: number;
retries?: number;
}
interface RuntimeContext {
requestJson: (request: HttpGetRequest) => Promise<unknown>;
apiKey: string;
apiHost: string;
timeoutMs: number | undefined;
retries: number | undefined;
}
function getRequiredString(flags: Record<string, unknown>, key: string): string {
const value = flags[key];
if (typeof value === "string" && value.length > 0) {
return value;
}
throw new CliError(`Missing required argument: --${key}`, ExitCode.INTERNAL);
}
function getOptionalString(flags: Record<string, unknown>, key: string): string | undefined {
const value = flags[key];
if (typeof value === "string") {
return value;
}
return undefined;
}
async function runQuery(
path: string,
params: Record<string, string | undefined>,
context: RuntimeContext,
): Promise<unknown> {
const payload = await context.requestJson({
url: `${context.apiHost}${path}`,
params: { ...params, key: context.apiKey },
timeoutMs: context.timeoutMs,
retries: context.retries,
});
if (!isRecord(payload)) {
throw new CliError(
"Unexpected API response format",
ExitCode.API_BUSINESS,
{ rawResponse: payload },
);
}
if (payload.code !== "200") {
throw new CliError(
`QWeather API error (code ${payload.code})`,
ExitCode.API_BUSINESS,
{ rawResponse: payload },
);
}
return payload;
}
export async function executeCommand(
command: CommandName,
flags: Record<string, unknown>,
dependencies: ExecuteCommandDependencies = {},
): Promise<unknown> {
const context: RuntimeContext = {
requestJson: dependencies.requestJson ?? getJsonWithRetry,
apiKey: dependencies.apiKey ?? getApiKey(),
apiHost: dependencies.apiHost ?? getApiHost(),
timeoutMs: dependencies.timeoutMs,
retries: dependencies.retries,
};
switch (command) {
case "lookup": {
const location = getRequiredString(flags, "location");
const adm = getOptionalString(flags, "adm");
const range = getOptionalString(flags, "range");
return runQuery("/geo/v2/city/lookup", {
location,
adm,
range,
}, context);
}
case "now": {
const location = getRequiredString(flags, "location");
const lang = getRequiredString(flags, "lang");
const unit = getRequiredString(flags, "unit");
return runQuery( "/v7/weather/now", {
location,
lang,
unit,
}, context);
}
case "forecast": {
const location = getRequiredString(flags, "location");
const days = getRequiredString(flags, "days");
const lang = getRequiredString(flags, "lang");
const unit = getRequiredString(flags, "unit");
return runQuery( `/v7/weather/${days}d`, {
location,
lang,
unit,
}, context);
}
case "indices": {
const location = getRequiredString(flags, "location");
const type = getRequiredString(flags, "type");
return runQuery( "/v7/indices/1d", {
location,
type,
}, context);
}
default: {
const unhandled: never = command;
throw new CliError(`Unsupported command: ${unhandled}`, ExitCode.INTERNAL);
}
}
}
export const API_KEY_ENV = "QWEATHER_API_KEY";
export const API_HOST_ENV = "QWEATHER_API_HOST";
export const DEFAULT_TIMEOUT_MS = 15_000;
export const DEFAULT_RETRY_COUNT = 2;
export const DEFAULT_BACKOFF_MS = 200;
export enum ExitCode {
OK = 0,
PARAM_OR_CONFIG = 2,
NETWORK = 3,
API_BUSINESS = 4,
INTERNAL = 5,
}
export interface CliErrorOptions {
cause?: unknown;
rawResponse?: unknown;
}
export class CliError extends Error {
public readonly exitCode: ExitCode;
public readonly rawResponse?: unknown;
constructor(message: string, exitCode: ExitCode, options: CliErrorOptions = {}) {
super(message, { cause: options.cause });
this.name = "CliError";
this.exitCode = exitCode;
this.rawResponse = options.rawResponse;
}
}
export function getApiKey(env: NodeJS.ProcessEnv = process.env): string {
const key = env[API_KEY_ENV];
if (!key || key.trim().length === 0) {
throw new CliError(
`${API_KEY_ENV} is required. Get one at https://console.qweather.com`,
ExitCode.PARAM_OR_CONFIG,
);
}
return key.trim();
}
export function getApiHost(env: NodeJS.ProcessEnv = process.env): string {
const host = env[API_HOST_ENV];
if (!host || host.trim().length === 0) {
throw new CliError(
`${API_HOST_ENV} is required. Find it at https://console.qweather.com/setting`,
ExitCode.PARAM_OR_CONFIG,
);
}
const trimmed = host.trim();
return trimmed.startsWith("https://") ? trimmed : `https://${trimmed}`;
}
export function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
import {
CliError,
DEFAULT_BACKOFF_MS,
DEFAULT_RETRY_COUNT,
DEFAULT_TIMEOUT_MS,
ExitCode,
toErrorMessage,
} from "./config.ts";
export interface HttpGetRequest {
url: string;
params: Record<string, string | undefined>;
timeoutMs?: number;
retries?: number;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function buildUrlWithParams(url: string, params: Record<string, string | undefined>): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (typeof value === "string") {
query.set(key, value);
}
}
const queryString = query.toString();
if (queryString.length === 0) {
return url;
}
return `${url}?${queryString}`;
}
export async function getJsonWithRetry(request: HttpGetRequest): Promise<unknown> {
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const retries = request.retries ?? DEFAULT_RETRY_COUNT;
const urlWithParams = buildUrlWithParams(request.url, request.params);
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const isLastAttempt = attempt === retries;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(urlWithParams, {
method: "GET",
signal: controller.signal,
headers: {
Accept: "application/json",
},
});
clearTimeout(timer);
if (!response.ok) {
if (!isLastAttempt && (response.status >= 500 || response.status === 429)) {
await sleep(DEFAULT_BACKOFF_MS * 2 ** attempt);
continue;
}
throw new CliError(
`HTTP request failed with status ${response.status} for ${urlWithParams}`,
ExitCode.NETWORK,
);
}
try {
return await response.json();
} catch (jsonError) {
throw new CliError(
`Failed to parse JSON response from ${urlWithParams}: ${toErrorMessage(jsonError)}`,
ExitCode.NETWORK,
{ cause: jsonError },
);
}
} catch (error) {
lastError = error;
if (error instanceof CliError && error.exitCode !== ExitCode.NETWORK) {
throw error;
}
if (!isLastAttempt) {
await sleep(DEFAULT_BACKOFF_MS * 2 ** attempt);
continue;
}
}
}
if (lastError instanceof CliError) {
throw lastError;
}
throw new CliError(
`Network request failed after retries: ${toErrorMessage(lastError)}`,
ExitCode.NETWORK,
{ cause: lastError },
);
}
import { CliError, ExitCode } from "./config.ts";
export type CommandName = "lookup" | "now" | "forecast" | "indices";
interface CommandFlagMap {
lookup: {
location: string;
adm?: string;
range?: string;
};
now: {
location: string;
lang: string;
unit: string;
};
forecast: {
location: string;
days: string;
lang: string;
unit: string;
};
indices: {
location: string;
type: string;
};
}
export type ValidatedFlags<K extends CommandName> = CommandFlagMap[K];
export interface CommandHelp {
usage: string;
description: string;
}
export const COMMAND_HELP_MAP: Record<CommandName, CommandHelp> = {
lookup: {
usage: "lookup --location <name|lon,lat> [--adm <text>] [--range <country>]",
description: "Search for a city and get its LocationID.",
},
now: {
usage: "now --location <LocationID|lon,lat> [--lang <zh|en>] [--unit <m|i>]",
description: "Get real-time weather for a location.",
},
forecast: {
usage: "forecast --location <LocationID|lon,lat> [--days <3|7>] [--lang <zh|en>] [--unit <m|i>]",
description: "Get daily weather forecast (3 or 7 days).",
},
indices: {
usage: "indices --location <LocationID|lon,lat> [--type <0|1,2,3...>]",
description: "Get life index forecast (clothing, UV, car wash, etc.).",
},
};
export const COMMAND_ORDER: CommandName[] = ["lookup", "now", "forecast", "indices"];
const COMMAND_NAME_SET = new Set<string>(COMMAND_ORDER);
const ALLOWED_FLAG_NAMES: Record<CommandName, readonly string[]> = {
lookup: ["location", "adm", "range"],
now: ["location", "lang", "unit"],
forecast: ["location", "days", "lang", "unit"],
indices: ["location", "type"],
};
function throwInvalidFlags(command: CommandName, message: string): never {
throw new CliError(`Invalid flags for ${command}: ${message}`, ExitCode.PARAM_OR_CONFIG);
}
function ensureNoUnknownFlags(command: CommandName, rawFlags: Record<string, string>): void {
const allowed = new Set(ALLOWED_FLAG_NAMES[command]);
const unknownFlags: string[] = [];
for (const key of Object.keys(rawFlags)) {
if (!allowed.has(key)) {
unknownFlags.push(`--${key}`);
}
}
if (unknownFlags.length > 0) {
throwInvalidFlags(command, `unknown flags: ${unknownFlags.join(", ")}`);
}
}
function normalizeOptionalString(command: CommandName, rawFlags: Record<string, string>, key: string): string | undefined {
const value = rawFlags[key];
if (value === undefined) {
return undefined;
}
const normalized = value.trim();
if (normalized.length === 0) {
throwInvalidFlags(command, `${key}: must be a non-empty string`);
}
return normalized;
}
function normalizeRequiredString(command: CommandName, rawFlags: Record<string, string>, key: string): string {
const normalized = normalizeOptionalString(command, rawFlags, key);
if (!normalized) {
throwInvalidFlags(command, `${key}: is required`);
}
return normalized;
}
export function isCommandName(value: string): value is CommandName {
return COMMAND_NAME_SET.has(value);
}
export function validateCommandFlags<K extends CommandName>(
command: K,
rawFlags: Record<string, string>,
): ValidatedFlags<K> {
ensureNoUnknownFlags(command, rawFlags);
switch (command) {
case "lookup": {
const location = normalizeRequiredString(command, rawFlags, "location");
const adm = normalizeOptionalString(command, rawFlags, "adm");
const range = normalizeOptionalString(command, rawFlags, "range");
return { location, adm, range } as ValidatedFlags<K>;
}
case "now": {
const location = normalizeRequiredString(command, rawFlags, "location");
const lang = normalizeOptionalString(command, rawFlags, "lang") ?? "zh";
const unit = normalizeOptionalString(command, rawFlags, "unit") ?? "m";
if (unit !== "m" && unit !== "i") {
throwInvalidFlags(command, `unit: must be "m" (metric) or "i" (imperial)`);
}
return { location, lang, unit } as ValidatedFlags<K>;
}
case "forecast": {
const location = normalizeRequiredString(command, rawFlags, "location");
const days = normalizeOptionalString(command, rawFlags, "days") ?? "3";
if (days !== "3" && days !== "7") {
throwInvalidFlags(command, `days: must be 3 or 7`);
}
const lang = normalizeOptionalString(command, rawFlags, "lang") ?? "zh";
const unit = normalizeOptionalString(command, rawFlags, "unit") ?? "m";
if (unit !== "m" && unit !== "i") {
throwInvalidFlags(command, `unit: must be "m" (metric) or "i" (imperial)`);
}
return { location, days, lang, unit } as ValidatedFlags<K>;
}
case "indices": {
const location = normalizeRequiredString(command, rawFlags, "location");
const type = normalizeOptionalString(command, rawFlags, "type") ?? "0";
return { location, type } as ValidatedFlags<K>;
}
default: {
const unhandled: never = command;
throwInvalidFlags(command, `unsupported command: ${String(unhandled)}`);
}
}
}
#!/usr/bin/env bun
import { executeCommand } from "./lib/commands.ts";
import { CliError, ExitCode, toErrorMessage } from "./lib/config.ts";
import {
COMMAND_HELP_MAP,
COMMAND_ORDER,
isCommandName,
validateCommandFlags,
} from "./lib/validators.ts";
function printJson(payload: unknown): void {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
}
function printGlobalHelp(): void {
const lines: string[] = [];
lines.push("QWeather CLI (和风天气)");
lines.push("");
lines.push("Usage:");
lines.push(" bun scripts/weather.ts <command> [flags]");
lines.push("");
lines.push("Commands:");
for (const command of COMMAND_ORDER) {
const help = COMMAND_HELP_MAP[command];
lines.push(` ${help.usage}`);
}
lines.push("");
lines.push("Tips:");
lines.push(" - Set QWEATHER_API_KEY and QWEATHER_API_HOST before running commands");
lines.push(" - Use `lookup` to find LocationID for a city name");
lines.push(" - Use --help after a command for command-specific details");
process.stdout.write(`${lines.join("\n")}\n`);
}
function printCommandHelp(command: keyof typeof COMMAND_HELP_MAP): void {
const help = COMMAND_HELP_MAP[command];
process.stdout.write(`Usage: ${help.usage}\n${help.description}\n`);
}
function parseFlags(tokens: string[]): Record<string, string> {
const flags: Record<string, string> = {};
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) {
continue;
}
if (!token.startsWith("--")) {
throw new CliError(`Invalid token: ${token}. Use --key value format.`, ExitCode.PARAM_OR_CONFIG);
}
const trimmed = token.slice(2);
if (trimmed.length === 0) {
throw new CliError("Empty flag name is not allowed.", ExitCode.PARAM_OR_CONFIG);
}
let key = trimmed;
let value: string | undefined;
const equalIndex = trimmed.indexOf("=");
if (equalIndex >= 0) {
key = trimmed.slice(0, equalIndex);
value = trimmed.slice(equalIndex + 1);
} else {
const next = tokens[index + 1];
if (!next || next.startsWith("--")) {
throw new CliError(`Flag --${key} is missing a value.`, ExitCode.PARAM_OR_CONFIG);
}
value = next;
index += 1;
}
if (key.length === 0) {
throw new CliError("Flag name cannot be empty.", ExitCode.PARAM_OR_CONFIG);
}
if (Object.hasOwn(flags, key)) {
throw new CliError(`Duplicate flag: --${key}`, ExitCode.PARAM_OR_CONFIG);
}
flags[key] = value;
}
return flags;
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
printGlobalHelp();
process.exit(ExitCode.OK);
}
const commandToken = args[0];
if (!commandToken || !isCommandName(commandToken)) {
throw new CliError(`Unknown command: ${commandToken ?? ""}`, ExitCode.PARAM_OR_CONFIG);
}
const restArgs = args.slice(1);
if (restArgs.length === 1 && (restArgs[0] === "--help" || restArgs[0] === "-h")) {
printCommandHelp(commandToken);
process.exit(ExitCode.OK);
}
const rawFlags = parseFlags(restArgs);
const validatedFlags = validateCommandFlags(commandToken, rawFlags);
const response = await executeCommand(commandToken, validatedFlags as Record<string, unknown>);
printJson(response);
process.exit(ExitCode.OK);
}
void main().catch((error: unknown) => {
if (error instanceof CliError) {
if (error.rawResponse !== undefined) {
printJson(error.rawResponse);
} else {
process.stderr.write(`${error.message}\n`);
}
process.exit(error.exitCode);
}
process.stderr.write(`Unexpected internal error: ${toErrorMessage(error)}\n`);
process.exit(ExitCode.INTERNAL);
});