initial commit

This commit is contained in:
Hunter
2026-11-02 18:25:49 -05:00
commit 1367da24b0
36 changed files with 19448 additions and 0 deletions

View File

@@ -0,0 +1,328 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "5c77481b",
"metadata": {},
"outputs": [],
"source": [
"# Requirements\n",
"import numpy as np\n",
"import pandas as pd\n",
"import io\n",
"import json\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "d0b3427f",
"metadata": {},
"outputs": [],
"source": [
"path = rf\"D:\\Project\\Py\\WowheadScraper\\quest_analyzer\\resources\\quests.jsonl\"\n",
"df = pd.read_json(path, lines=True)\n",
"\n",
"if df is None:\n",
" raise Exception(\"DataFrame is None. Failed to read the CSV file.\")\n",
"\n",
"df.describe()\n",
"\n",
"horde_only_quests = df[(df[\"side\"] == \"Horde\") | (df[\"side\"] == \"Both\")]\n",
"all_zones = df[[\"category\", \"category2\"]].drop_duplicates().to_dict(orient='records')\n",
"\n",
"\n",
"def locations_contains_zone(row, zone_name):\n",
" locations = row.get(\"locations\")\n",
" if not isinstance(locations, list):\n",
" return False\n",
" for location in locations:\n",
" if location.get(\"role\") == \"start\" and zone_name in location[\"zone\"]:\n",
" return True\n",
" if location.get(\"role\") == \"end\" and zone_name in location[\"zone\"]:\n",
" return True\n",
" return False\n",
"\n",
"def get_quests_by_zone(_df: pd.DataFrame, zone_name: str) -> pd.DataFrame:\n",
" location_mask = _df.apply(lambda row: locations_contains_zone(row, zone_name), axis=1)\n",
" category_mask = (_df[\"category\"] == zone_name) | (_df[\"category2\"] == zone_name)\n",
" return _df[location_mask | category_mask]\n",
"\n",
"durotar_quests = get_quests_by_zone(horde_only_quests, \"Durotar\")\n",
"mulgore_quests = get_quests_by_zone(horde_only_quests, \"Mulgore\")\n",
"tirisfal_glades_quests = get_quests_by_zone(horde_only_quests, \"Tirisfal Glades\")\n",
"zephras_quests = get_quests_by_zone(horde_only_quests, \"Zephras Isle\")\n",
"\n",
"print(f\"Total quests for each starting zone:\")\n",
"print(f\"Durotar: {len(durotar_quests)}\")\n",
"print(f\"Mulgore: {len(mulgore_quests)}\")\n",
"print(f\"Tirisfal Glades: {len(tirisfal_glades_quests)}\")\n",
"print(f\"Zephras Isle: {len(zephras_quests)}\")\n",
"\n",
"# Now we limit the search to level brackets 1-10 and 11-20 for each zone\n",
"\n",
"durotar_quests_1_10 = durotar_quests[(durotar_quests[\"level\"] >= 1) & (durotar_quests[\"level\"] <= 10)]\n",
"durotar_quests_11_20 = durotar_quests[(durotar_quests[\"level\"] >= 11) & (durotar_quests[\"level\"] <= 20)]\n",
"\n",
"mulgore_quests_1_10 = mulgore_quests[(mulgore_quests[\"level\"] >= 1) & (mulgore_quests[\"level\"] <= 10)]\n",
"mulgore_quests_11_20 = mulgore_quests[(mulgore_quests[\"level\"] >= 11) & (mulgore_quests[\"level\"] <= 20)]\n",
"\n",
"tirisfal_glades_quests_1_10 = tirisfal_glades_quests[(tirisfal_glades_quests[\"level\"] >= 1) & (tirisfal_glades_quests[\"level\"] <= 10)]\n",
"tirisfal_glades_quests_11_20 = tirisfal_glades_quests[(tirisfal_glades_quests[\"level\"] >= 11) & (tirisfal_glades_quests[\"level\"] <= 20)]\n",
"\n",
"zephras_quests_1_10 = zephras_quests[(zephras_quests[\"level\"] >= 1) & (zephras_quests[\"level\"] <= 10)]\n",
"zephras_quests_11_20 = zephras_quests[(zephras_quests[\"level\"] >= 11) & (zephras_quests[\"level\"] <= 20)]\n",
"\n",
"print(f\"\\n\\nTotal quests for each starting zone and level bracket:\")\n",
"print(f\"Durotar 1-10: {len(durotar_quests_1_10)}\")\n",
"print(f\"Durotar 11-20: {len(durotar_quests_11_20)}\")\n",
"print(f\"Mulgore 1-10: {len(mulgore_quests_1_10)}\")\n",
"print(f\"Mulgore 11-20: {len(mulgore_quests_11_20)}\")\n",
"print(f\"Tirisfal Glades 1-10: {len(tirisfal_glades_quests_1_10)}\")\n",
"print(f\"Tirisfal Glades 11-20: {len(tirisfal_glades_quests_11_20)}\")\n",
"print(f\"Zephras Isle 1-10: {len(zephras_quests_1_10)}\")\n",
"print(f\"Zephras Isle 11-20: {len(zephras_quests_11_20)}\")\n",
"\n",
"\n",
"# Now we calculate the total experience available from quests for each zone and level bracket\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "e826b3eb",
"metadata": {},
"outputs": [],
"source": [
"brackets = {\n",
" (\"Durotar\", \"1-10\"): durotar_quests_1_10,\n",
" (\"Durotar\", \"11-20\"): durotar_quests_11_20,\n",
" (\"Mulgore\", \"1-10\"): mulgore_quests_1_10,\n",
" (\"Mulgore\", \"11-20\"): mulgore_quests_11_20,\n",
" (\"Tirisfal Glades\", \"1-10\"): tirisfal_glades_quests_1_10,\n",
" (\"Tirisfal Glades\", \"11-20\"): tirisfal_glades_quests_11_20,\n",
" (\"Zephras Isle\", \"1-10\"): zephras_quests_1_10,\n",
" (\"Zephras Isle\", \"11-20\"): zephras_quests_11_20,\n",
"}\n",
"\n",
"def quest_xp(rewards) -> int:\n",
" if isinstance(rewards, dict) and rewards.get(\"xp\"):\n",
" return rewards[\"xp\"]\n",
" return 0\n",
"\n",
"xp_summary = pd.DataFrame([\n",
" {\"zone\": zone, \"bracket\": bracket, \"quests\": len(quests), \"total_xp\": quests[\"rewards\"].apply(quest_xp).sum()}\n",
" for (zone, bracket), quests in brackets.items()\n",
"])\n",
"xp_summary[\"avg_xp\"] = (xp_summary[\"total_xp\"] / xp_summary[\"quests\"]).round(0)\n",
"xp_summary"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "ab76fa6f",
"metadata": {},
"outputs": [],
"source": [
"# Now we try to calculate travel requirements for each quest. We define travel as the distance from the quest giver to the objective area, and then from the objective area to the quest turn-in. For delivery/talk-to quests, we just measure straight from giver to turn-in.\n",
"# An additional challenge is that the coordinates given are percentage based based off of the map frame.\n",
"# Some digging online found some map size estimates, and they are the closest I have to a reliable source for now.\n",
"\n",
"OBJECTIVE_ROLES = (\"requirement\", \"sourcerequirement\")\n",
"\n",
"def zone_coords(locations, roles, zone_name):\n",
" return [loc[\"coord\"] for loc in locations\n",
" if loc.get(\"role\") in roles and loc.get(\"zone\") == zone_name and loc.get(\"coord\")]\n",
"\n",
"def distance(a, b):\n",
" if a is None or b is None:\n",
" return np.nan\n",
" return float(np.linalg.norm(np.subtract(a, b)))\n",
"\n",
"def quest_travel(row, zone_name):\n",
" locations = row[\"locations\"] if isinstance(row[\"locations\"], list) else []\n",
" starts = zone_coords(locations, (\"start\",), zone_name)\n",
" ends = zone_coords(locations, (\"end\",), zone_name)\n",
" objectives = zone_coords(locations, OBJECTIVE_ROLES, zone_name)\n",
"\n",
" start = starts[0] if starts else None\n",
" end = ends[0] if ends else None\n",
" # Objective area = mean of every objective npc/object's representative coord\n",
" objective = np.mean(objectives, axis=0) if objectives else None\n",
"\n",
" if objective is None:\n",
" # Delivery / talk-to quests: straight from giver to turn-in\n",
" to_objective, from_objective = np.nan, np.nan\n",
" total = distance(start, end)\n",
" else:\n",
" to_objective = distance(start, objective)\n",
" from_objective = distance(objective, end)\n",
" total = to_objective + from_objective\n",
"\n",
" return pd.Series({\n",
" \"start_x\": start[0] if start else np.nan,\n",
" \"start_y\": start[1] if start else np.nan,\n",
" \"objective_x\": objective[0] if objective is not None else np.nan,\n",
" \"objective_y\": objective[1] if objective is not None else np.nan,\n",
" \"end_x\": end[0] if end else np.nan,\n",
" \"end_y\": end[1] if end else np.nan,\n",
" \"to_objective\": to_objective,\n",
" \"from_objective\": from_objective,\n",
" \"total_travel\": total,\n",
" })\n",
"\n",
"def build_travel(quests: pd.DataFrame, zone_name: str, bracket: str) -> pd.DataFrame:\n",
" travel = quests.apply(lambda row: quest_travel(row, zone_name), axis=1)\n",
" travel.insert(0, \"zone\", zone_name)\n",
" travel.insert(1, \"bracket\", bracket)\n",
" travel.insert(2, \"id\", quests[\"id\"])\n",
" travel.insert(3, \"name\", quests[\"name\"])\n",
" travel.insert(4, \"level\", quests[\"level\"])\n",
" travel.insert(5, \"xp\", quests[\"rewards\"].apply(quest_xp))\n",
" return travel\n",
"\n",
"travel_df = pd.concat(\n",
" [build_travel(quests, zone, bracket) for (zone, bracket), quests in brackets.items()],\n",
" ignore_index=True,\n",
")\n",
"\n",
"travel_summary = travel_df.groupby([\"zone\", \"bracket\"]).agg(\n",
" quests=(\"id\", \"count\"),\n",
" with_objective=(\"objective_x\", \"count\"),\n",
" total_xp=(\"xp\", \"sum\"),\n",
" total_to_objective=(\"to_objective\", \"sum\"),\n",
" total_from_objective=(\"from_objective\", \"sum\"),\n",
" total_travel=(\"total_travel\", \"sum\"),\n",
" avg_travel=(\"total_travel\", \"mean\"),\n",
")\n",
"travel_summary[\"xp_per_travel\"] = (travel_summary[\"total_xp\"] / travel_summary[\"total_travel\"]).round(1)\n",
"travel_summary.round(1)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7fe45672",
"metadata": {},
"outputs": [],
"source": [
"# Afterwards, we use X-means clustering to find hotspots for quest objective locations.\n",
"# X-means = k-means that keeps splitting clusters in two while the split improves the\n",
"# BIC score (Pelleg & Moore, 2000), so we don't have to pick k ourselves.\n",
"\n",
"def kmeans(points: np.ndarray, centers: np.ndarray, iterations: int = 100):\n",
" for _ in range(iterations):\n",
" labels = np.argmin(np.linalg.norm(points[:, None] - centers[None], axis=2), axis=1)\n",
" new_centers = np.array([\n",
" points[labels == k].mean(axis=0) if np.any(labels == k) else centers[k]\n",
" for k in range(len(centers))\n",
" ])\n",
" if np.allclose(new_centers, centers):\n",
" break\n",
" centers = new_centers\n",
" return labels, centers\n",
"\n",
"def bic(points: np.ndarray, labels: np.ndarray, centers: np.ndarray, min_variance: float = 1.25) -> float:\n",
" r, m = points.shape\n",
" k = len(centers)\n",
" if r <= k:\n",
" return -np.inf\n",
" # Many quests share the exact same objective coord, so without a floor a tiny cluster gets ~0 variance and an infinite likelihood. 1.0 = roughly 1% of the map.\n",
" variance = max(np.sum((points - centers[labels]) ** 2) / (m * (r - k)), min_variance)\n",
" log_likelihood = 0.0\n",
" for i in range(k):\n",
" r_i = np.sum(labels == i)\n",
" if r_i == 0:\n",
" continue\n",
" log_likelihood += (r_i * np.log(r_i) - r_i * np.log(r)\n",
" - r_i * m / 2 * np.log(2 * np.pi * variance)\n",
" - (r_i - 1) * m / 2)\n",
" free_params = k * (m + 1)\n",
" return log_likelihood - free_params / 2 * np.log(r)\n",
"\n",
"def best_split(members: np.ndarray, rng, attempts: int):\n",
" # k-means only finds a local optimum, so try a few random starts and keep the best split\n",
" best_score, best_children = -np.inf, None\n",
" for _ in range(attempts):\n",
" child_labels, children = kmeans(members, members[rng.choice(len(members), 2, replace=False)])\n",
" score = bic(members, child_labels, children)\n",
" if score > best_score:\n",
" best_score, best_children = score, children\n",
" return best_score, best_children\n",
"\n",
"def xmeans(points: np.ndarray, k_max: int = 15, split_attempts: int = 10, seed: int = 0):\n",
" rng = np.random.default_rng(seed)\n",
" labels, centers = kmeans(points, points.mean(axis=0, keepdims=True))\n",
" score = bic(points, labels, centers)\n",
" while True:\n",
" new_centers = []\n",
" for k, center in enumerate(centers):\n",
" members = points[labels == k]\n",
" if len(members) < 4:\n",
" new_centers.append(center)\n",
" continue\n",
" parent_score = bic(members, np.zeros(len(members), dtype=int), center[None])\n",
" child_score, children = best_split(members, rng, split_attempts)\n",
" new_centers.extend(children if child_score > parent_score else [center])\n",
" if len(new_centers) == len(centers) or len(new_centers) > k_max:\n",
" return labels, centers\n",
" # The per-cluster test is lenient on small clusters, so only keep the splits if they also improve the BIC of the whole zone\n",
" new_labels, new_centers = kmeans(points, np.array(new_centers))\n",
" new_score = bic(points, new_labels, new_centers)\n",
" if new_score <= score:\n",
" return labels, centers\n",
" labels, centers, score = new_labels, new_centers, new_score\n",
"\n",
"# Cluster each zone + bracket's objective areas separately, then classify every questin the travel data by the hotspot its objective falls in.\n",
"# Hotspot ids are only unique within a zone + bracket.\n",
"travel_df[\"objective_hotspot\"] = pd.NA\n",
"travel_df[\"hotspot_distance\"] = np.nan\n",
"hotspots = {}\n",
"\n",
"for (zone_name, bracket), bracket_travel in travel_df.groupby([\"zone\", \"bracket\"]):\n",
" has_objective = bracket_travel.dropna(subset=[\"objective_x\", \"objective_y\"])\n",
" points = has_objective[[\"objective_x\", \"objective_y\"]].to_numpy()\n",
" if len(points) < 2:\n",
" continue\n",
" labels, centers = xmeans(points)\n",
" hotspots[(zone_name, bracket)] = centers\n",
" travel_df.loc[has_objective.index, \"objective_hotspot\"] = labels\n",
" travel_df.loc[has_objective.index, \"hotspot_distance\"] = np.linalg.norm(points - centers[labels], axis=1)\n",
" print(f\"{zone_name} {bracket}: {len(centers)} hotspots from {len(points)} objectives\")\n",
"\n",
"hotspot_summary = travel_df.dropna(subset=[\"objective_hotspot\"]).groupby([\"zone\", \"bracket\", \"objective_hotspot\"]).agg(\n",
" center_x=(\"objective_x\", \"mean\"),\n",
" center_y=(\"objective_y\", \"mean\"),\n",
" quests=(\"id\", \"count\"),\n",
" total_xp=(\"xp\", \"sum\"),\n",
" avg_to_objective=(\"to_objective\", \"mean\"),\n",
" avg_from_objective=(\"from_objective\", \"mean\"),\n",
")\n",
"hotspot_summary.round(1)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv (3.14.6)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.14.6"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
zone,bracket,quests,total_xp,avg_xp
Ashenvale,19-21,7,2150,307.0
Blackrock Depths,1-3,1,0,0.0
Darkmoon Faire,1-3,1,0,0.0
Druid,1-3,2,125,62.0
Druid,10-12,11,4540,413.0
Druid,13-15,5,3300,660.0
Druid,16-18,4,3025,756.0
Druid,19-21,8,5225,653.0
Durotar,1-3,20,1540,77.0
Durotar,10-12,38,21430,564.0
Durotar,13-15,6,4005,668.0
Durotar,4-6,35,10525,301.0
Durotar,7-9,19,12045,634.0
Herbalism,16-18,1,1150,1150.0
Hillsbrad Foothills,19-21,3,2085,695.0
Hunter,1-3,4,205,51.0
Hunter,10-12,19,9540,502.0
Loch Modan,10-12,1,225,225.0
Mage,1-3,3,120,40.0
Mage,10-12,4,1850,462.0
Mage,13-15,3,1955,652.0
Mage,16-18,1,1150,1150.0
Mage,19-21,1,0,0.0
Miscellaneous,10-12,2,1260,630.0
Miscellaneous,19-21,1,0,0.0
Miscellaneous,7-9,1,530,530.0
Mulgore,1-3,11,1165,106.0
Mulgore,10-12,29,16585,572.0
Mulgore,13-15,4,2120,530.0
Mulgore,4-6,32,10420,326.0
Mulgore,7-9,17,8725,513.0
Orgrimmar,10-12,29,11235,387.0
Orgrimmar,13-15,13,8335,641.0
Orgrimmar,16-18,9,4750,528.0
Orgrimmar,19-21,21,11560,550.0
Paladin,1-3,2,125,62.0
Paladin,10-12,13,7505,577.0
Paladin,4-6,2,270,135.0
Priest,1-3,2,80,40.0
Priest,10-12,12,2100,175.0
Priest,13-15,3,540,180.0
Priest,19-21,6,1560,260.0
Priest,4-6,4,720,180.0
Ragefire Chasm,13-15,1,1050,1050.0
Ragefire Chasm,16-18,5,6080,1216.0
Rogue,1-3,4,205,51.0
Rogue,10-12,2,420,210.0
Rogue,13-15,6,3185,531.0
Rogue,16-18,6,2575,429.0
Rogue,19-21,4,3010,752.0
Ruins of Lordaeron,19-21,4,6600,1650.0
Shaman,1-3,4,205,51.0
Shaman,10-12,11,6820,620.0
Shaman,13-15,3,2300,767.0
Shaman,19-21,5,3120,624.0
Shaman,4-6,8,1880,235.0
Silverpine Forest,10-12,17,9665,569.0
Silverpine Forest,13-15,13,10675,821.0
Silverpine Forest,16-18,14,12125,866.0
Silverpine Forest,19-21,5,7050,1410.0
Stonetalon Mountains,16-18,7,7395,1056.0
Stonetalon Mountains,19-21,11,13020,1184.0
The Barrens,10-12,25,11905,476.0
The Barrens,13-15,31,25190,813.0
The Barrens,16-18,35,37795,1080.0
The Barrens,19-21,46,47470,1032.0
Thunder Bluff,10-12,30,11545,385.0
Thunder Bluff,13-15,7,4825,689.0
Thunder Bluff,16-18,10,8070,807.0
Thunder Bluff,19-21,9,4595,511.0
Thunder Bluff,7-9,3,1660,553.0
Tirisfal Glades,1-3,15,1470,98.0
Tirisfal Glades,10-12,45,24840,552.0
Tirisfal Glades,13-15,4,2835,709.0
Tirisfal Glades,4-6,32,9940,311.0
Tirisfal Glades,7-9,16,8805,550.0
Uncategorized,1-3,23,1060,46.0
Uncategorized,10-12,29,16645,574.0
Uncategorized,13-15,7,3045,435.0
Uncategorized,19-21,16,2480,155.0
Uncategorized,4-6,72,20930,291.0
Uncategorized,7-9,30,15090,503.0
Undercity,13-15,11,7420,675.0
Undercity,16-18,10,9680,968.0
Undercity,19-21,36,23690,658.0
Undercity,7-9,4,1585,396.0
Wailing Caverns,16-18,6,6405,1068.0
Wailing Caverns,19-21,1,2050,2050.0
Warlock,1-3,4,310,78.0
Warlock,10-12,6,3220,537.0
Warlock,19-21,15,13850,923.0
Warlock,4-6,2,390,195.0
Warrior,1-3,5,245,49.0
Warrior,10-12,10,5280,528.0
Warrior,19-21,3,2485,828.0
Warsong Gulch,19-21,2,2180,1090.0
Westfall,16-18,5,4040,808.0
Westfall,19-21,2,3000,1500.0
Winter Veil,1-3,4,10,2.0
Winter Veil,10-12,1,0,0.0
Winter Veil,19-21,1,0,0.0
Zephras Isle,1-3,13,1475,113.0
Zephras Isle,10-12,41,25465,621.0
Zephras Isle,13-15,7,3045,435.0
Zephras Isle,16-18,0,0,
Zephras Isle,19-21,0,0,
Zephras Isle,4-6,30,8780,293.0
Zephras Isle,7-9,30,15090,503.0
1 zone bracket quests total_xp avg_xp
2 Ashenvale 19-21 7 2150 307.0
3 Blackrock Depths 1-3 1 0 0.0
4 Darkmoon Faire 1-3 1 0 0.0
5 Druid 1-3 2 125 62.0
6 Druid 10-12 11 4540 413.0
7 Druid 13-15 5 3300 660.0
8 Druid 16-18 4 3025 756.0
9 Druid 19-21 8 5225 653.0
10 Durotar 1-3 20 1540 77.0
11 Durotar 10-12 38 21430 564.0
12 Durotar 13-15 6 4005 668.0
13 Durotar 4-6 35 10525 301.0
14 Durotar 7-9 19 12045 634.0
15 Herbalism 16-18 1 1150 1150.0
16 Hillsbrad Foothills 19-21 3 2085 695.0
17 Hunter 1-3 4 205 51.0
18 Hunter 10-12 19 9540 502.0
19 Loch Modan 10-12 1 225 225.0
20 Mage 1-3 3 120 40.0
21 Mage 10-12 4 1850 462.0
22 Mage 13-15 3 1955 652.0
23 Mage 16-18 1 1150 1150.0
24 Mage 19-21 1 0 0.0
25 Miscellaneous 10-12 2 1260 630.0
26 Miscellaneous 19-21 1 0 0.0
27 Miscellaneous 7-9 1 530 530.0
28 Mulgore 1-3 11 1165 106.0
29 Mulgore 10-12 29 16585 572.0
30 Mulgore 13-15 4 2120 530.0
31 Mulgore 4-6 32 10420 326.0
32 Mulgore 7-9 17 8725 513.0
33 Orgrimmar 10-12 29 11235 387.0
34 Orgrimmar 13-15 13 8335 641.0
35 Orgrimmar 16-18 9 4750 528.0
36 Orgrimmar 19-21 21 11560 550.0
37 Paladin 1-3 2 125 62.0
38 Paladin 10-12 13 7505 577.0
39 Paladin 4-6 2 270 135.0
40 Priest 1-3 2 80 40.0
41 Priest 10-12 12 2100 175.0
42 Priest 13-15 3 540 180.0
43 Priest 19-21 6 1560 260.0
44 Priest 4-6 4 720 180.0
45 Ragefire Chasm 13-15 1 1050 1050.0
46 Ragefire Chasm 16-18 5 6080 1216.0
47 Rogue 1-3 4 205 51.0
48 Rogue 10-12 2 420 210.0
49 Rogue 13-15 6 3185 531.0
50 Rogue 16-18 6 2575 429.0
51 Rogue 19-21 4 3010 752.0
52 Ruins of Lordaeron 19-21 4 6600 1650.0
53 Shaman 1-3 4 205 51.0
54 Shaman 10-12 11 6820 620.0
55 Shaman 13-15 3 2300 767.0
56 Shaman 19-21 5 3120 624.0
57 Shaman 4-6 8 1880 235.0
58 Silverpine Forest 10-12 17 9665 569.0
59 Silverpine Forest 13-15 13 10675 821.0
60 Silverpine Forest 16-18 14 12125 866.0
61 Silverpine Forest 19-21 5 7050 1410.0
62 Stonetalon Mountains 16-18 7 7395 1056.0
63 Stonetalon Mountains 19-21 11 13020 1184.0
64 The Barrens 10-12 25 11905 476.0
65 The Barrens 13-15 31 25190 813.0
66 The Barrens 16-18 35 37795 1080.0
67 The Barrens 19-21 46 47470 1032.0
68 Thunder Bluff 10-12 30 11545 385.0
69 Thunder Bluff 13-15 7 4825 689.0
70 Thunder Bluff 16-18 10 8070 807.0
71 Thunder Bluff 19-21 9 4595 511.0
72 Thunder Bluff 7-9 3 1660 553.0
73 Tirisfal Glades 1-3 15 1470 98.0
74 Tirisfal Glades 10-12 45 24840 552.0
75 Tirisfal Glades 13-15 4 2835 709.0
76 Tirisfal Glades 4-6 32 9940 311.0
77 Tirisfal Glades 7-9 16 8805 550.0
78 Uncategorized 1-3 23 1060 46.0
79 Uncategorized 10-12 29 16645 574.0
80 Uncategorized 13-15 7 3045 435.0
81 Uncategorized 19-21 16 2480 155.0
82 Uncategorized 4-6 72 20930 291.0
83 Uncategorized 7-9 30 15090 503.0
84 Undercity 13-15 11 7420 675.0
85 Undercity 16-18 10 9680 968.0
86 Undercity 19-21 36 23690 658.0
87 Undercity 7-9 4 1585 396.0
88 Wailing Caverns 16-18 6 6405 1068.0
89 Wailing Caverns 19-21 1 2050 2050.0
90 Warlock 1-3 4 310 78.0
91 Warlock 10-12 6 3220 537.0
92 Warlock 19-21 15 13850 923.0
93 Warlock 4-6 2 390 195.0
94 Warrior 1-3 5 245 49.0
95 Warrior 10-12 10 5280 528.0
96 Warrior 19-21 3 2485 828.0
97 Warsong Gulch 19-21 2 2180 1090.0
98 Westfall 16-18 5 4040 808.0
99 Westfall 19-21 2 3000 1500.0
100 Winter Veil 1-3 4 10 2.0
101 Winter Veil 10-12 1 0 0.0
102 Winter Veil 19-21 1 0 0.0
103 Zephras Isle 1-3 13 1475 113.0
104 Zephras Isle 10-12 41 25465 621.0
105 Zephras Isle 13-15 7 3045 435.0
106 Zephras Isle 16-18 0 0
107 Zephras Isle 19-21 0 0
108 Zephras Isle 4-6 30 8780 293.0
109 Zephras Isle 7-9 30 15090 503.0

View File

@@ -0,0 +1,109 @@
zone,bracket,quests,total_xp,avg_xp
Ashenvale,7: 19-21,7,2150,307.0
Blackrock Depths,1: 1-3,1,0,0.0
Darkmoon Faire,1: 1-3,1,0,0.0
Druid,1: 1-3,2,125,62.0
Druid,4: 10-12,11,4540,413.0
Druid,5: 13-15,5,3300,660.0
Druid,6: 16-18,4,3025,756.0
Druid,7: 19-21,8,5225,653.0
Durotar,1: 1-3,20,1540,77.0
Durotar,2: 4-6,35,10525,301.0
Durotar,3: 7-9,19,12045,634.0
Durotar,4: 10-12,38,21430,564.0
Durotar,5: 13-15,6,4005,668.0
Herbalism,6: 16-18,1,1150,1150.0
Hillsbrad Foothills,7: 19-21,3,2085,695.0
Hunter,1: 1-3,4,205,51.0
Hunter,4: 10-12,19,9540,502.0
Loch Modan,4: 10-12,1,225,225.0
Mage,1: 1-3,3,120,40.0
Mage,4: 10-12,4,1850,462.0
Mage,5: 13-15,3,1955,652.0
Mage,6: 16-18,1,1150,1150.0
Mage,7: 19-21,1,0,0.0
Miscellaneous,3: 7-9,1,530,530.0
Miscellaneous,4: 10-12,2,1260,630.0
Miscellaneous,7: 19-21,1,0,0.0
Mulgore,1: 1-3,11,1165,106.0
Mulgore,2: 4-6,32,10420,326.0
Mulgore,3: 7-9,17,8725,513.0
Mulgore,4: 10-12,29,16585,572.0
Mulgore,5: 13-15,4,2120,530.0
Orgrimmar,4: 10-12,29,11235,387.0
Orgrimmar,5: 13-15,13,8335,641.0
Orgrimmar,6: 16-18,9,4750,528.0
Orgrimmar,7: 19-21,21,11560,550.0
Paladin,1: 1-3,2,125,62.0
Paladin,2: 4-6,2,270,135.0
Paladin,4: 10-12,13,7505,577.0
Priest,1: 1-3,2,80,40.0
Priest,2: 4-6,4,720,180.0
Priest,4: 10-12,12,2100,175.0
Priest,5: 13-15,3,540,180.0
Priest,7: 19-21,6,1560,260.0
Ragefire Chasm,5: 13-15,1,1050,1050.0
Ragefire Chasm,6: 16-18,5,6080,1216.0
Rogue,1: 1-3,4,205,51.0
Rogue,4: 10-12,2,420,210.0
Rogue,5: 13-15,6,3185,531.0
Rogue,6: 16-18,6,2575,429.0
Rogue,7: 19-21,4,3010,752.0
Ruins of Lordaeron,7: 19-21,4,6600,1650.0
Shaman,1: 1-3,4,205,51.0
Shaman,2: 4-6,8,1880,235.0
Shaman,4: 10-12,11,6820,620.0
Shaman,5: 13-15,3,2300,767.0
Shaman,7: 19-21,5,3120,624.0
Silverpine Forest,4: 10-12,17,9665,569.0
Silverpine Forest,5: 13-15,13,10675,821.0
Silverpine Forest,6: 16-18,14,12125,866.0
Silverpine Forest,7: 19-21,5,7050,1410.0
Stonetalon Mountains,6: 16-18,7,7395,1056.0
Stonetalon Mountains,7: 19-21,11,13020,1184.0
The Barrens,4: 10-12,25,11905,476.0
The Barrens,5: 13-15,31,25190,813.0
The Barrens,6: 16-18,35,37795,1080.0
The Barrens,7: 19-21,46,47470,1032.0
Thunder Bluff,3: 7-9,3,1660,553.0
Thunder Bluff,4: 10-12,30,11545,385.0
Thunder Bluff,5: 13-15,7,4825,689.0
Thunder Bluff,6: 16-18,10,8070,807.0
Thunder Bluff,7: 19-21,9,4595,511.0
Tirisfal Glades,1: 1-3,15,1470,98.0
Tirisfal Glades,2: 4-6,32,9940,311.0
Tirisfal Glades,3: 7-9,16,8805,550.0
Tirisfal Glades,4: 10-12,45,24840,552.0
Tirisfal Glades,5: 13-15,4,2835,709.0
Uncategorized,1: 1-3,23,1060,46.0
Uncategorized,2: 4-6,72,20930,291.0
Uncategorized,3: 7-9,30,15090,503.0
Uncategorized,4: 10-12,29,16645,574.0
Uncategorized,5: 13-15,7,3045,435.0
Uncategorized,7: 19-21,16,2480,155.0
Undercity,3: 7-9,4,1585,396.0
Undercity,5: 13-15,11,7420,675.0
Undercity,6: 16-18,10,9680,968.0
Undercity,7: 19-21,36,23690,658.0
Wailing Caverns,6: 16-18,6,6405,1068.0
Wailing Caverns,7: 19-21,1,2050,2050.0
Warlock,1: 1-3,4,310,78.0
Warlock,2: 4-6,2,390,195.0
Warlock,4: 10-12,6,3220,537.0
Warlock,7: 19-21,15,13850,923.0
Warrior,1: 1-3,5,245,49.0
Warrior,4: 10-12,10,5280,528.0
Warrior,7: 19-21,3,2485,828.0
Warsong Gulch,7: 19-21,2,2180,1090.0
Westfall,6: 16-18,5,4040,808.0
Westfall,7: 19-21,2,3000,1500.0
Winter Veil,1: 1-3,4,10,2.0
Winter Veil,4: 10-12,1,0,0.0
Winter Veil,7: 19-21,1,0,0.0
Zephras Isle,1: 1-3,13,1475,113.0
Zephras Isle,2: 4-6,30,8780,293.0
Zephras Isle,3: 7-9,30,15090,503.0
Zephras Isle,4: 10-12,41,25465,621.0
Zephras Isle,5: 13-15,7,3045,435.0
Zephras Isle,6: 16-18,0,0,
Zephras Isle,7: 19-21,0,0,
1 zone bracket quests total_xp avg_xp
2 Ashenvale 7: 19-21 7 2150 307.0
3 Blackrock Depths 1: 1-3 1 0 0.0
4 Darkmoon Faire 1: 1-3 1 0 0.0
5 Druid 1: 1-3 2 125 62.0
6 Druid 4: 10-12 11 4540 413.0
7 Druid 5: 13-15 5 3300 660.0
8 Druid 6: 16-18 4 3025 756.0
9 Druid 7: 19-21 8 5225 653.0
10 Durotar 1: 1-3 20 1540 77.0
11 Durotar 2: 4-6 35 10525 301.0
12 Durotar 3: 7-9 19 12045 634.0
13 Durotar 4: 10-12 38 21430 564.0
14 Durotar 5: 13-15 6 4005 668.0
15 Herbalism 6: 16-18 1 1150 1150.0
16 Hillsbrad Foothills 7: 19-21 3 2085 695.0
17 Hunter 1: 1-3 4 205 51.0
18 Hunter 4: 10-12 19 9540 502.0
19 Loch Modan 4: 10-12 1 225 225.0
20 Mage 1: 1-3 3 120 40.0
21 Mage 4: 10-12 4 1850 462.0
22 Mage 5: 13-15 3 1955 652.0
23 Mage 6: 16-18 1 1150 1150.0
24 Mage 7: 19-21 1 0 0.0
25 Miscellaneous 3: 7-9 1 530 530.0
26 Miscellaneous 4: 10-12 2 1260 630.0
27 Miscellaneous 7: 19-21 1 0 0.0
28 Mulgore 1: 1-3 11 1165 106.0
29 Mulgore 2: 4-6 32 10420 326.0
30 Mulgore 3: 7-9 17 8725 513.0
31 Mulgore 4: 10-12 29 16585 572.0
32 Mulgore 5: 13-15 4 2120 530.0
33 Orgrimmar 4: 10-12 29 11235 387.0
34 Orgrimmar 5: 13-15 13 8335 641.0
35 Orgrimmar 6: 16-18 9 4750 528.0
36 Orgrimmar 7: 19-21 21 11560 550.0
37 Paladin 1: 1-3 2 125 62.0
38 Paladin 2: 4-6 2 270 135.0
39 Paladin 4: 10-12 13 7505 577.0
40 Priest 1: 1-3 2 80 40.0
41 Priest 2: 4-6 4 720 180.0
42 Priest 4: 10-12 12 2100 175.0
43 Priest 5: 13-15 3 540 180.0
44 Priest 7: 19-21 6 1560 260.0
45 Ragefire Chasm 5: 13-15 1 1050 1050.0
46 Ragefire Chasm 6: 16-18 5 6080 1216.0
47 Rogue 1: 1-3 4 205 51.0
48 Rogue 4: 10-12 2 420 210.0
49 Rogue 5: 13-15 6 3185 531.0
50 Rogue 6: 16-18 6 2575 429.0
51 Rogue 7: 19-21 4 3010 752.0
52 Ruins of Lordaeron 7: 19-21 4 6600 1650.0
53 Shaman 1: 1-3 4 205 51.0
54 Shaman 2: 4-6 8 1880 235.0
55 Shaman 4: 10-12 11 6820 620.0
56 Shaman 5: 13-15 3 2300 767.0
57 Shaman 7: 19-21 5 3120 624.0
58 Silverpine Forest 4: 10-12 17 9665 569.0
59 Silverpine Forest 5: 13-15 13 10675 821.0
60 Silverpine Forest 6: 16-18 14 12125 866.0
61 Silverpine Forest 7: 19-21 5 7050 1410.0
62 Stonetalon Mountains 6: 16-18 7 7395 1056.0
63 Stonetalon Mountains 7: 19-21 11 13020 1184.0
64 The Barrens 4: 10-12 25 11905 476.0
65 The Barrens 5: 13-15 31 25190 813.0
66 The Barrens 6: 16-18 35 37795 1080.0
67 The Barrens 7: 19-21 46 47470 1032.0
68 Thunder Bluff 3: 7-9 3 1660 553.0
69 Thunder Bluff 4: 10-12 30 11545 385.0
70 Thunder Bluff 5: 13-15 7 4825 689.0
71 Thunder Bluff 6: 16-18 10 8070 807.0
72 Thunder Bluff 7: 19-21 9 4595 511.0
73 Tirisfal Glades 1: 1-3 15 1470 98.0
74 Tirisfal Glades 2: 4-6 32 9940 311.0
75 Tirisfal Glades 3: 7-9 16 8805 550.0
76 Tirisfal Glades 4: 10-12 45 24840 552.0
77 Tirisfal Glades 5: 13-15 4 2835 709.0
78 Uncategorized 1: 1-3 23 1060 46.0
79 Uncategorized 2: 4-6 72 20930 291.0
80 Uncategorized 3: 7-9 30 15090 503.0
81 Uncategorized 4: 10-12 29 16645 574.0
82 Uncategorized 5: 13-15 7 3045 435.0
83 Uncategorized 7: 19-21 16 2480 155.0
84 Undercity 3: 7-9 4 1585 396.0
85 Undercity 5: 13-15 11 7420 675.0
86 Undercity 6: 16-18 10 9680 968.0
87 Undercity 7: 19-21 36 23690 658.0
88 Wailing Caverns 6: 16-18 6 6405 1068.0
89 Wailing Caverns 7: 19-21 1 2050 2050.0
90 Warlock 1: 1-3 4 310 78.0
91 Warlock 2: 4-6 2 390 195.0
92 Warlock 4: 10-12 6 3220 537.0
93 Warlock 7: 19-21 15 13850 923.0
94 Warrior 1: 1-3 5 245 49.0
95 Warrior 4: 10-12 10 5280 528.0
96 Warrior 7: 19-21 3 2485 828.0
97 Warsong Gulch 7: 19-21 2 2180 1090.0
98 Westfall 6: 16-18 5 4040 808.0
99 Westfall 7: 19-21 2 3000 1500.0
100 Winter Veil 1: 1-3 4 10 2.0
101 Winter Veil 4: 10-12 1 0 0.0
102 Winter Veil 7: 19-21 1 0 0.0
103 Zephras Isle 1: 1-3 13 1475 113.0
104 Zephras Isle 2: 4-6 30 8780 293.0
105 Zephras Isle 3: 7-9 30 15090 503.0
106 Zephras Isle 4: 10-12 41 25465 621.0
107 Zephras Isle 5: 13-15 7 3045 435.0
108 Zephras Isle 6: 16-18 0 0
109 Zephras Isle 7: 19-21 0 0

View File

@@ -0,0 +1,107 @@
zone,bracket,quests,with_objective,total_xp,total_to_objective,total_from_objective,total_travel,avg_travel,xp_per_travel
Ashenvale,7: 19-21,7,3,2150,86.22196857922745,86.22196857922745,172.4439371584549,43.110984289613725,12.5
Blackrock Depths,1: 1-3,1,0,0,0.0,0.0,0.0,,
Darkmoon Faire,1: 1-3,1,0,0,0.0,0.0,0.0,,
Druid,1: 1-3,2,0,125,0.0,0.0,0.0,,inf
Druid,4: 10-12,11,0,4540,0.0,0.0,0.0,,inf
Druid,5: 13-15,5,0,3300,0.0,0.0,0.0,,inf
Druid,6: 16-18,4,0,3025,0.0,0.0,0.0,,inf
Druid,7: 19-21,8,0,5225,0.0,0.0,0.0,,inf
Durotar,1: 1-3,20,2,1540,7.2496867579747235,7.2496867579747235,30.238752090540732,1.6799306716967073,50.9
Durotar,2: 4-6,35,8,10525,88.02397548674963,83.42527428521277,349.3591211947182,12.93922671091549,30.1
Durotar,3: 7-9,19,6,12045,96.01873824079449,96.01873824079449,232.18705387598337,15.479136925065557,51.9
Durotar,4: 10-12,38,13,21430,167.21238250331842,167.21238250331842,402.6859851873704,22.37144362152058,53.2
Durotar,5: 13-15,6,1,4005,0.5,0.4123105625617633,0.9123105625617633,0.4561552812808817,4390.0
Herbalism,6: 16-18,1,0,1150,0.0,0.0,0.0,,inf
Hillsbrad Foothills,7: 19-21,3,0,2085,0.0,0.0,49.53804374193411,24.769021870967055,42.1
Hunter,1: 1-3,4,0,205,0.0,0.0,0.0,,inf
Hunter,4: 10-12,19,0,9540,0.0,0.0,0.0,,inf
Loch Modan,4: 10-12,1,0,225,0.0,0.0,0.0,,inf
Mage,1: 1-3,3,0,120,0.0,0.0,0.0,,inf
Mage,4: 10-12,4,0,1850,0.0,0.0,0.0,,inf
Mage,5: 13-15,3,0,1955,0.0,0.0,0.0,,inf
Mage,6: 16-18,1,0,1150,0.0,0.0,0.0,,inf
Mage,7: 19-21,1,0,0,0.0,0.0,0.0,,
Miscellaneous,3: 7-9,1,0,530,0.0,0.0,0.0,,inf
Miscellaneous,4: 10-12,2,0,1260,0.0,0.0,0.0,,inf
Miscellaneous,7: 19-21,1,0,0,0.0,0.0,0.0,,
Mulgore,1: 1-3,11,2,1165,15.016765583909526,15.016765583909526,64.20695016256053,6.420695016256053,18.1
Mulgore,2: 4-6,32,7,10420,96.15671294456787,91.41318248010322,303.51664696952895,13.196375955196912,34.3
Mulgore,3: 7-9,17,7,8725,67.14906023022141,67.14906023022141,200.3751243453758,13.358341623025053,43.5
Mulgore,4: 10-12,29,7,16585,109.14986184665257,92.74152938439545,179.4939094457104,17.94939094457104,92.4
Mulgore,5: 13-15,4,0,2120,0.0,0.0,0.0,,inf
Orgrimmar,4: 10-12,29,3,11235,66.6682266317423,66.6682266317423,143.17769302278322,14.317769302278322,78.5
Orgrimmar,5: 13-15,13,2,8335,20.607473974613356,20.607473974613356,114.77562613212774,12.752847348014193,72.6
Orgrimmar,6: 16-18,9,0,4750,0.0,0.0,22.3785663382064,5.5946415845516,212.3
Orgrimmar,7: 19-21,21,2,11560,9.648892006374707,9.648892006374707,53.59341479474561,4.466117899562135,215.7
Paladin,1: 1-3,2,0,125,0.0,0.0,0.0,,inf
Paladin,2: 4-6,2,0,270,0.0,0.0,0.0,,inf
Paladin,4: 10-12,13,0,7505,0.0,0.0,0.0,,inf
Priest,1: 1-3,2,0,80,0.0,0.0,0.0,,inf
Priest,2: 4-6,4,0,720,0.0,0.0,0.0,,inf
Priest,4: 10-12,12,0,2100,0.0,0.0,0.0,,inf
Priest,5: 13-15,3,0,540,0.0,0.0,0.0,,inf
Priest,7: 19-21,6,0,1560,0.0,0.0,0.0,,inf
Ragefire Chasm,5: 13-15,1,0,1050,0.0,0.0,0.0,,inf
Ragefire Chasm,6: 16-18,5,0,6080,0.0,0.0,0.0,,inf
Rogue,1: 1-3,4,0,205,0.0,0.0,0.0,,inf
Rogue,4: 10-12,2,0,420,0.0,0.0,0.0,,inf
Rogue,5: 13-15,6,0,3185,0.0,0.0,0.0,,inf
Rogue,6: 16-18,6,0,2575,0.0,0.0,0.0,,inf
Rogue,7: 19-21,4,0,3010,0.0,0.0,0.0,,inf
Ruins of Lordaeron,7: 19-21,4,0,6600,0.0,0.0,0.0,,inf
Shaman,1: 1-3,4,0,205,0.0,0.0,0.0,,inf
Shaman,2: 4-6,8,0,1880,0.0,0.0,0.0,,inf
Shaman,4: 10-12,11,0,6820,0.0,0.0,0.0,,inf
Shaman,5: 13-15,3,0,2300,0.0,0.0,0.0,,inf
Shaman,7: 19-21,5,0,3120,0.0,0.0,0.0,,inf
Silverpine Forest,4: 10-12,17,5,9665,62.84900392030974,55.482907861999436,229.08623704351567,19.090519753626307,42.2
Silverpine Forest,5: 13-15,13,4,10675,73.58580941279655,73.58580941279655,190.0637154217843,21.11819060242048,56.2
Silverpine Forest,6: 16-18,14,4,12125,121.59726463408211,91.80316366368211,218.47823083334725,31.21117583333532,55.5
Silverpine Forest,7: 19-21,5,3,7050,116.64678523566371,116.64678523566371,233.29357047132743,77.76452349044247,30.2
Stonetalon Mountains,6: 16-18,7,3,7395,29.031685770877647,29.031685770877647,58.063371541755295,58.063371541755295,127.4
Stonetalon Mountains,7: 19-21,11,5,13020,73.75785042074442,80.10713311165387,153.86498353239827,38.46624588309957,84.6
The Barrens,4: 10-12,25,2,11905,7.917834652519845,7.917834652519845,86.58476283999335,8.658476283999335,137.5
The Barrens,5: 13-15,31,12,25190,106.26158293857759,106.26158293857759,274.209754384025,12.46407974472841,91.9
The Barrens,6: 16-18,35,19,37795,229.9489971737371,231.2475440436149,550.3258938868469,19.65449621024453,68.7
The Barrens,7: 19-21,46,20,47470,208.72855582400518,209.74167113110244,496.02927419544613,16.534309139848204,95.7
Thunder Bluff,3: 7-9,3,1,1660,5.702144952974186,5.702144952974186,11.404289905948373,11.404289905948373,145.6
Thunder Bluff,4: 10-12,30,2,11545,48.7382207185583,48.7382207185583,151.33684524232066,15.133684524232066,76.3
Thunder Bluff,5: 13-15,7,0,4825,0.0,0.0,0.0,0.0,inf
Thunder Bluff,6: 16-18,10,0,8070,0.0,0.0,52.02189621860649,17.340632072868832,155.1
Thunder Bluff,7: 19-21,9,0,4595,0.0,0.0,0.0,0.0,inf
Tirisfal Glades,1: 1-3,15,5,1470,40.25494036916621,40.25494036916621,90.13212886750887,6.933240682116066,16.3
Tirisfal Glades,2: 4-6,32,11,9940,96.91431336829991,64.66016634811038,350.2220496377767,12.076622401302645,28.4
Tirisfal Glades,3: 7-9,16,7,8805,104.3267005186916,104.3267005186916,281.23572692502677,21.63351745577129,31.3
Tirisfal Glades,4: 10-12,45,16,24840,239.4624763900186,272.9735205879254,683.3871411878215,18.982976144106154,36.3
Tirisfal Glades,5: 13-15,4,3,2835,48.23489964898144,48.23489964898144,96.46979929796288,48.23489964898144,29.4
Uncategorized,1: 1-3,23,0,1060,0.0,0.0,0.0,,inf
Uncategorized,2: 4-6,72,0,20930,0.0,0.0,0.0,,inf
Uncategorized,3: 7-9,30,0,15090,0.0,0.0,0.0,,inf
Uncategorized,4: 10-12,29,0,16645,0.0,0.0,0.0,,inf
Uncategorized,5: 13-15,7,0,3045,0.0,0.0,0.0,,inf
Uncategorized,7: 19-21,16,0,2480,0.0,0.0,0.0,,inf
Undercity,3: 7-9,4,0,1585,0.0,0.0,0.0,0.0,inf
Undercity,5: 13-15,11,1,7420,38.25748291972724,18.57045004630836,151.85004641939378,25.30834106989896,48.9
Undercity,6: 16-18,10,0,9680,0.0,0.0,22.253285400387767,5.563321350096942,435.0
Undercity,7: 19-21,36,1,23690,1.9075548306926227,1.9075548306926227,52.5200230196221,3.0894131188013,451.1
Wailing Caverns,6: 16-18,6,0,6405,0.0,0.0,0.0,,inf
Wailing Caverns,7: 19-21,1,0,2050,0.0,0.0,0.0,,inf
Warlock,1: 1-3,4,0,310,0.0,0.0,0.0,,inf
Warlock,2: 4-6,2,0,390,0.0,0.0,0.0,,inf
Warlock,4: 10-12,6,0,3220,0.0,0.0,0.0,,inf
Warlock,7: 19-21,15,0,13850,0.0,0.0,0.0,,inf
Warrior,1: 1-3,5,0,245,0.0,0.0,0.0,,inf
Warrior,4: 10-12,10,0,5280,0.0,0.0,0.0,,inf
Warrior,7: 19-21,3,0,2485,0.0,0.0,0.0,,inf
Warsong Gulch,7: 19-21,2,0,2180,0.0,0.0,0.0,,inf
Westfall,6: 16-18,5,1,4040,54.90280123824708,54.90280123824708,169.65665621242402,42.414164053106006,23.8
Westfall,7: 19-21,2,2,3000,46.411668147220254,46.411668147220254,92.82333629444051,46.411668147220254,32.3
Winter Veil,1: 1-3,4,0,10,0.0,0.0,0.0,,inf
Winter Veil,4: 10-12,1,0,0,0.0,0.0,0.0,,
Winter Veil,7: 19-21,1,0,0,0.0,0.0,0.0,,
Zephras Isle,1: 1-3,13,4,1475,14.526626524171727,14.526626524171727,42.18306281701619,3.2448509859243226,35.0
Zephras Isle,2: 4-6,30,9,8780,62.595018153829784,62.595018153829784,251.40715666795938,8.380238555598646,34.9
Zephras Isle,3: 7-9,30,17,15090,182.35203024809363,183.20317500773706,501.8040603510491,16.726802011701636,30.1
Zephras Isle,4: 10-12,41,20,25465,251.0154804558676,186.694070988593,645.9601977575601,16.149004943939,39.4
Zephras Isle,5: 13-15,7,3,3045,23.09501377109359,23.09501377109359,54.02903258109359,10.805806516218718,56.4
1 zone bracket quests with_objective total_xp total_to_objective total_from_objective total_travel avg_travel xp_per_travel
2 Ashenvale 7: 19-21 7 3 2150 86.22196857922745 86.22196857922745 172.4439371584549 43.110984289613725 12.5
3 Blackrock Depths 1: 1-3 1 0 0 0.0 0.0 0.0
4 Darkmoon Faire 1: 1-3 1 0 0 0.0 0.0 0.0
5 Druid 1: 1-3 2 0 125 0.0 0.0 0.0 inf
6 Druid 4: 10-12 11 0 4540 0.0 0.0 0.0 inf
7 Druid 5: 13-15 5 0 3300 0.0 0.0 0.0 inf
8 Druid 6: 16-18 4 0 3025 0.0 0.0 0.0 inf
9 Druid 7: 19-21 8 0 5225 0.0 0.0 0.0 inf
10 Durotar 1: 1-3 20 2 1540 7.2496867579747235 7.2496867579747235 30.238752090540732 1.6799306716967073 50.9
11 Durotar 2: 4-6 35 8 10525 88.02397548674963 83.42527428521277 349.3591211947182 12.93922671091549 30.1
12 Durotar 3: 7-9 19 6 12045 96.01873824079449 96.01873824079449 232.18705387598337 15.479136925065557 51.9
13 Durotar 4: 10-12 38 13 21430 167.21238250331842 167.21238250331842 402.6859851873704 22.37144362152058 53.2
14 Durotar 5: 13-15 6 1 4005 0.5 0.4123105625617633 0.9123105625617633 0.4561552812808817 4390.0
15 Herbalism 6: 16-18 1 0 1150 0.0 0.0 0.0 inf
16 Hillsbrad Foothills 7: 19-21 3 0 2085 0.0 0.0 49.53804374193411 24.769021870967055 42.1
17 Hunter 1: 1-3 4 0 205 0.0 0.0 0.0 inf
18 Hunter 4: 10-12 19 0 9540 0.0 0.0 0.0 inf
19 Loch Modan 4: 10-12 1 0 225 0.0 0.0 0.0 inf
20 Mage 1: 1-3 3 0 120 0.0 0.0 0.0 inf
21 Mage 4: 10-12 4 0 1850 0.0 0.0 0.0 inf
22 Mage 5: 13-15 3 0 1955 0.0 0.0 0.0 inf
23 Mage 6: 16-18 1 0 1150 0.0 0.0 0.0 inf
24 Mage 7: 19-21 1 0 0 0.0 0.0 0.0
25 Miscellaneous 3: 7-9 1 0 530 0.0 0.0 0.0 inf
26 Miscellaneous 4: 10-12 2 0 1260 0.0 0.0 0.0 inf
27 Miscellaneous 7: 19-21 1 0 0 0.0 0.0 0.0
28 Mulgore 1: 1-3 11 2 1165 15.016765583909526 15.016765583909526 64.20695016256053 6.420695016256053 18.1
29 Mulgore 2: 4-6 32 7 10420 96.15671294456787 91.41318248010322 303.51664696952895 13.196375955196912 34.3
30 Mulgore 3: 7-9 17 7 8725 67.14906023022141 67.14906023022141 200.3751243453758 13.358341623025053 43.5
31 Mulgore 4: 10-12 29 7 16585 109.14986184665257 92.74152938439545 179.4939094457104 17.94939094457104 92.4
32 Mulgore 5: 13-15 4 0 2120 0.0 0.0 0.0 inf
33 Orgrimmar 4: 10-12 29 3 11235 66.6682266317423 66.6682266317423 143.17769302278322 14.317769302278322 78.5
34 Orgrimmar 5: 13-15 13 2 8335 20.607473974613356 20.607473974613356 114.77562613212774 12.752847348014193 72.6
35 Orgrimmar 6: 16-18 9 0 4750 0.0 0.0 22.3785663382064 5.5946415845516 212.3
36 Orgrimmar 7: 19-21 21 2 11560 9.648892006374707 9.648892006374707 53.59341479474561 4.466117899562135 215.7
37 Paladin 1: 1-3 2 0 125 0.0 0.0 0.0 inf
38 Paladin 2: 4-6 2 0 270 0.0 0.0 0.0 inf
39 Paladin 4: 10-12 13 0 7505 0.0 0.0 0.0 inf
40 Priest 1: 1-3 2 0 80 0.0 0.0 0.0 inf
41 Priest 2: 4-6 4 0 720 0.0 0.0 0.0 inf
42 Priest 4: 10-12 12 0 2100 0.0 0.0 0.0 inf
43 Priest 5: 13-15 3 0 540 0.0 0.0 0.0 inf
44 Priest 7: 19-21 6 0 1560 0.0 0.0 0.0 inf
45 Ragefire Chasm 5: 13-15 1 0 1050 0.0 0.0 0.0 inf
46 Ragefire Chasm 6: 16-18 5 0 6080 0.0 0.0 0.0 inf
47 Rogue 1: 1-3 4 0 205 0.0 0.0 0.0 inf
48 Rogue 4: 10-12 2 0 420 0.0 0.0 0.0 inf
49 Rogue 5: 13-15 6 0 3185 0.0 0.0 0.0 inf
50 Rogue 6: 16-18 6 0 2575 0.0 0.0 0.0 inf
51 Rogue 7: 19-21 4 0 3010 0.0 0.0 0.0 inf
52 Ruins of Lordaeron 7: 19-21 4 0 6600 0.0 0.0 0.0 inf
53 Shaman 1: 1-3 4 0 205 0.0 0.0 0.0 inf
54 Shaman 2: 4-6 8 0 1880 0.0 0.0 0.0 inf
55 Shaman 4: 10-12 11 0 6820 0.0 0.0 0.0 inf
56 Shaman 5: 13-15 3 0 2300 0.0 0.0 0.0 inf
57 Shaman 7: 19-21 5 0 3120 0.0 0.0 0.0 inf
58 Silverpine Forest 4: 10-12 17 5 9665 62.84900392030974 55.482907861999436 229.08623704351567 19.090519753626307 42.2
59 Silverpine Forest 5: 13-15 13 4 10675 73.58580941279655 73.58580941279655 190.0637154217843 21.11819060242048 56.2
60 Silverpine Forest 6: 16-18 14 4 12125 121.59726463408211 91.80316366368211 218.47823083334725 31.21117583333532 55.5
61 Silverpine Forest 7: 19-21 5 3 7050 116.64678523566371 116.64678523566371 233.29357047132743 77.76452349044247 30.2
62 Stonetalon Mountains 6: 16-18 7 3 7395 29.031685770877647 29.031685770877647 58.063371541755295 58.063371541755295 127.4
63 Stonetalon Mountains 7: 19-21 11 5 13020 73.75785042074442 80.10713311165387 153.86498353239827 38.46624588309957 84.6
64 The Barrens 4: 10-12 25 2 11905 7.917834652519845 7.917834652519845 86.58476283999335 8.658476283999335 137.5
65 The Barrens 5: 13-15 31 12 25190 106.26158293857759 106.26158293857759 274.209754384025 12.46407974472841 91.9
66 The Barrens 6: 16-18 35 19 37795 229.9489971737371 231.2475440436149 550.3258938868469 19.65449621024453 68.7
67 The Barrens 7: 19-21 46 20 47470 208.72855582400518 209.74167113110244 496.02927419544613 16.534309139848204 95.7
68 Thunder Bluff 3: 7-9 3 1 1660 5.702144952974186 5.702144952974186 11.404289905948373 11.404289905948373 145.6
69 Thunder Bluff 4: 10-12 30 2 11545 48.7382207185583 48.7382207185583 151.33684524232066 15.133684524232066 76.3
70 Thunder Bluff 5: 13-15 7 0 4825 0.0 0.0 0.0 0.0 inf
71 Thunder Bluff 6: 16-18 10 0 8070 0.0 0.0 52.02189621860649 17.340632072868832 155.1
72 Thunder Bluff 7: 19-21 9 0 4595 0.0 0.0 0.0 0.0 inf
73 Tirisfal Glades 1: 1-3 15 5 1470 40.25494036916621 40.25494036916621 90.13212886750887 6.933240682116066 16.3
74 Tirisfal Glades 2: 4-6 32 11 9940 96.91431336829991 64.66016634811038 350.2220496377767 12.076622401302645 28.4
75 Tirisfal Glades 3: 7-9 16 7 8805 104.3267005186916 104.3267005186916 281.23572692502677 21.63351745577129 31.3
76 Tirisfal Glades 4: 10-12 45 16 24840 239.4624763900186 272.9735205879254 683.3871411878215 18.982976144106154 36.3
77 Tirisfal Glades 5: 13-15 4 3 2835 48.23489964898144 48.23489964898144 96.46979929796288 48.23489964898144 29.4
78 Uncategorized 1: 1-3 23 0 1060 0.0 0.0 0.0 inf
79 Uncategorized 2: 4-6 72 0 20930 0.0 0.0 0.0 inf
80 Uncategorized 3: 7-9 30 0 15090 0.0 0.0 0.0 inf
81 Uncategorized 4: 10-12 29 0 16645 0.0 0.0 0.0 inf
82 Uncategorized 5: 13-15 7 0 3045 0.0 0.0 0.0 inf
83 Uncategorized 7: 19-21 16 0 2480 0.0 0.0 0.0 inf
84 Undercity 3: 7-9 4 0 1585 0.0 0.0 0.0 0.0 inf
85 Undercity 5: 13-15 11 1 7420 38.25748291972724 18.57045004630836 151.85004641939378 25.30834106989896 48.9
86 Undercity 6: 16-18 10 0 9680 0.0 0.0 22.253285400387767 5.563321350096942 435.0
87 Undercity 7: 19-21 36 1 23690 1.9075548306926227 1.9075548306926227 52.5200230196221 3.0894131188013 451.1
88 Wailing Caverns 6: 16-18 6 0 6405 0.0 0.0 0.0 inf
89 Wailing Caverns 7: 19-21 1 0 2050 0.0 0.0 0.0 inf
90 Warlock 1: 1-3 4 0 310 0.0 0.0 0.0 inf
91 Warlock 2: 4-6 2 0 390 0.0 0.0 0.0 inf
92 Warlock 4: 10-12 6 0 3220 0.0 0.0 0.0 inf
93 Warlock 7: 19-21 15 0 13850 0.0 0.0 0.0 inf
94 Warrior 1: 1-3 5 0 245 0.0 0.0 0.0 inf
95 Warrior 4: 10-12 10 0 5280 0.0 0.0 0.0 inf
96 Warrior 7: 19-21 3 0 2485 0.0 0.0 0.0 inf
97 Warsong Gulch 7: 19-21 2 0 2180 0.0 0.0 0.0 inf
98 Westfall 6: 16-18 5 1 4040 54.90280123824708 54.90280123824708 169.65665621242402 42.414164053106006 23.8
99 Westfall 7: 19-21 2 2 3000 46.411668147220254 46.411668147220254 92.82333629444051 46.411668147220254 32.3
100 Winter Veil 1: 1-3 4 0 10 0.0 0.0 0.0 inf
101 Winter Veil 4: 10-12 1 0 0 0.0 0.0 0.0
102 Winter Veil 7: 19-21 1 0 0 0.0 0.0 0.0
103 Zephras Isle 1: 1-3 13 4 1475 14.526626524171727 14.526626524171727 42.18306281701619 3.2448509859243226 35.0
104 Zephras Isle 2: 4-6 30 9 8780 62.595018153829784 62.595018153829784 251.40715666795938 8.380238555598646 34.9
105 Zephras Isle 3: 7-9 30 17 15090 182.35203024809363 183.20317500773706 501.8040603510491 16.726802011701636 30.1
106 Zephras Isle 4: 10-12 41 20 25465 251.0154804558676 186.694070988593 645.9601977575601 16.149004943939 39.4
107 Zephras Isle 5: 13-15 7 3 3045 23.09501377109359 23.09501377109359 54.02903258109359 10.805806516218718 56.4

View File

@@ -0,0 +1,127 @@
zone,bracket,quests,with_objective,total_xp,total_to_objective,total_from_objective,total_travel,avg_travel,xp_per_travel
Alchemy,3: 7-9,1,0,700,0.0,0.0,0.0,,inf
Anvilmar,2: 4-6,1,0,405,0.0,0.0,0.0,,inf
Ashenvale,6: 16-18,5,0,4910,0.0,0.0,0.0,,inf
Ashenvale,7: 19-21,16,4,17250,43.48682271513522,73.4378044700066,169.29512805417096,14.10792733784758,101.9
Blackrock Depths,1: 1-3,1,0,0,0.0,0.0,0.0,,
Blacksmithing,4: 10-12,1,0,910,0.0,0.0,0.0,,inf
Blacksmithing,6: 16-18,1,0,1150,0.0,0.0,0.0,,inf
Cooking,3: 7-9,2,0,1260,0.0,0.0,0.0,,inf
Cooking,4: 10-12,1,0,910,0.0,0.0,0.0,,inf
Darkmoon Faire,1: 1-3,1,0,0,0.0,0.0,0.0,,
Darkshore,1: 1-3,2,0,0,0.0,0.0,0.0,0.0,
Darkshore,4: 10-12,13,6,9440,55.25260876595058,71.74918457229674,134.89102650757374,13.489102650757374,70.0
Darkshore,5: 13-15,25,6,18165,89.73167339632545,95.09239954545124,313.72205966930886,14.939145698538518,57.9
Darkshore,6: 16-18,24,8,25055,127.9146094434045,145.73004621768075,389.068006383087,22.886353316652176,64.4
Darkshore,7: 19-21,20,6,22680,159.5844363435948,159.5844363435948,608.4263538107444,46.80202721621111,37.3
Darnassus,4: 10-12,33,1,12680,63.82383333355096,63.82383333355096,268.18045603234043,17.878697068822696,47.3
Darnassus,5: 13-15,3,0,1570,0.0,0.0,0.0,,inf
Deeprun Tram,4: 10-12,2,0,1000,0.0,0.0,0.0,,inf
Druid,1: 1-3,2,0,125,0.0,0.0,0.0,,inf
Druid,4: 10-12,11,0,4540,0.0,0.0,0.0,,inf
Druid,5: 13-15,5,0,3300,0.0,0.0,0.0,,inf
Druid,6: 16-18,4,0,3025,0.0,0.0,0.0,,inf
Druid,7: 19-21,8,0,5225,0.0,0.0,0.0,,inf
Dun Morogh,1: 1-3,19,3,1390,23.253891449566066,23.253891449566066,101.82856998072529,5.989915881219135,13.7
Dun Morogh,2: 4-6,32,7,9720,50.08524493955694,50.08524493955694,313.19743687280356,11.18562274545727,31.0
Dun Morogh,3: 7-9,20,9,10015,68.86140632021898,68.86140632021898,216.3254391975046,10.81627195987523,46.3
Dun Morogh,4: 10-12,36,9,17170,68.3171822128801,76.85951191462325,302.2427492562249,16.79126384756805,56.8
Dun Morogh,5: 13-15,7,3,5930,15.756432626061837,12.79481144839578,42.78613417088434,14.262044723628113,138.6
Dun Morogh,6: 16-18,3,0,2715,0.0,0.0,0.0,,inf
Durotar,3: 7-9,1,0,780,0.0,0.0,0.0,0.0,inf
Durotar,4: 10-12,1,1,880,6.230228281888587,6.230228281888587,12.460456563777173,12.460456563777173,70.6
Duskwood,7: 19-21,6,3,4840,92.75232931209018,92.75232931209018,356.5280738259673,59.42134563766121,13.6
Dustwallow Marsh,7: 19-21,1,0,0,0.0,0.0,0.0,,
Elwynn Forest,1: 1-3,21,6,1615,69.79003749304204,78.46992582193789,174.62618608517332,9.190851899219648,9.2
Elwynn Forest,2: 4-6,34,7,8960,69.23047417066707,75.42530613926694,384.3753377194917,12.39920444256425,23.3
Elwynn Forest,3: 7-9,21,11,12720,177.34875083455742,166.88813990157476,382.9310664541583,19.146553322707916,33.2
Elwynn Forest,4: 10-12,37,7,19445,95.9792603985216,109.5204005260423,368.0893413128177,16.003884404905115,52.8
Fishing,4: 10-12,2,0,1365,0.0,0.0,0.0,,inf
Herbalism,6: 16-18,1,0,1150,0.0,0.0,0.0,,inf
Hunter,1: 1-3,3,0,165,0.0,0.0,0.0,,inf
Hunter,4: 10-12,23,0,12480,0.0,0.0,0.0,,inf
Ironforge,1: 1-3,5,0,55,0.0,0.0,44.01942177022199,11.004855442555497,1.2
Ironforge,4: 10-12,37,0,13095,0.0,0.0,139.0138443929029,10.693372645607916,94.2
Ironforge,5: 13-15,13,1,7505,49.48878485601215,49.48878485601215,166.74611077858142,27.791018463096904,45.0
Kharanos,4: 10-12,1,0,85,0.0,0.0,0.0,,inf
Leatherworking,6: 16-18,1,0,1350,0.0,0.0,0.0,,inf
Loch Modan,4: 10-12,13,4,8315,72.13647553589269,72.13647553589269,178.71534910648603,29.78589151774767,46.5
Loch Modan,5: 13-15,19,4,14375,97.86979000880527,97.86979000880527,301.86249093510827,23.220191610392945,47.6
Loch Modan,6: 16-18,14,7,14620,111.3865260164705,126.71951597741982,296.500435444576,26.954585040416,49.3
Loch Modan,7: 19-21,9,2,9825,86.69187657745643,88.34718184137823,229.88051265536777,32.84007323648111,42.7
Mage,1: 1-3,3,0,165,0.0,0.0,0.0,,inf
Mage,4: 10-12,6,0,3445,0.0,0.0,0.0,,inf
Mage,5: 13-15,3,0,1955,0.0,0.0,0.0,,inf
Mage,6: 16-18,1,0,1150,0.0,0.0,0.0,,inf
Mage,7: 19-21,1,0,0,0.0,0.0,0.0,,
Miscellaneous,2: 4-6,1,0,405,0.0,0.0,0.0,,inf
Miscellaneous,4: 10-12,2,0,1260,0.0,0.0,0.0,,inf
Miscellaneous,7: 19-21,1,0,0,0.0,0.0,0.0,,
Paladin,1: 1-3,2,0,80,0.0,0.0,0.0,,inf
Paladin,4: 10-12,13,0,1945,0.0,0.0,0.0,,inf
Paladin,5: 13-15,12,0,7300,0.0,0.0,0.0,,inf
Paladin,7: 19-21,4,0,1220,0.0,0.0,0.0,,inf
Priest,1: 1-3,4,0,160,0.0,0.0,0.0,,inf
Priest,2: 4-6,6,0,1080,0.0,0.0,0.0,,inf
Priest,4: 10-12,28,0,4620,0.0,0.0,0.0,,inf
Priest,5: 13-15,7,0,1620,0.0,0.0,0.0,,inf
Priest,7: 19-21,13,0,3510,0.0,0.0,0.0,,inf
Redridge Mountains,5: 13-15,10,0,5055,0.0,0.0,56.173623446759436,11.234724689351888,90.0
Redridge Mountains,6: 16-18,14,4,11775,63.97863749901971,63.97863749901971,145.4226604472326,24.237110074538766,81.0
Redridge Mountains,7: 19-21,10,6,13405,130.85022762249795,130.85022762249795,261.7004552449959,32.71255690562449,51.2
Rogue,1: 1-3,5,0,245,0.0,0.0,0.0,,inf
Rogue,4: 10-12,7,0,4410,0.0,0.0,0.0,,inf
Rogue,6: 16-18,6,0,1155,0.0,0.0,0.0,,inf
Rogue,7: 19-21,5,0,3245,0.0,0.0,0.0,,inf
Ruins of Lordaeron,7: 19-21,1,0,1650,0.0,0.0,0.0,,inf
Shaman,1: 1-3,1,0,40,0.0,0.0,0.0,,inf
Shaman,2: 4-6,4,0,985,0.0,0.0,0.0,,inf
Shaman,4: 10-12,3,0,1500,0.0,0.0,0.0,,inf
Shaman,5: 13-15,4,0,3480,0.0,0.0,0.0,,inf
Shaman,7: 19-21,2,0,780,0.0,0.0,0.0,,inf
Stonetalon Mountains,7: 19-21,12,2,12160,28.785210207029003,28.785210207029003,57.99468448276993,19.331561494256643,209.7
Stormwind City,1: 1-3,5,0,355,0.0,0.0,37.31255221936733,9.328138054841833,9.5
Stormwind City,4: 10-12,41,1,12405,2.6534505836740276,2.6534505836740276,94.1507884354622,6.276719229030814,131.8
Stormwind City,5: 13-15,21,3,13420,72.95021870001224,69.27331129740946,233.74537567420907,23.37453756742091,57.4
Stormwind City,6: 16-18,18,0,8530,0.0,0.0,209.3126255632573,29.901803651893903,40.8
Teldrassil,1: 1-3,12,3,1200,12.911301433847976,12.911301433847976,51.55593562478292,4.686903238616629,23.3
Teldrassil,2: 4-6,33,4,9875,37.75323029138802,37.75323029138802,292.4724855000118,10.832314277778217,33.8
Teldrassil,3: 7-9,20,9,11765,95.18597364882127,95.18597364882127,263.9455842809509,15.526210840055937,44.6
Teldrassil,4: 10-12,38,5,18245,57.63840505556519,57.63840505556519,218.74146445318962,12.152303580732756,83.4
The Barrens,5: 13-15,7,3,3890,10.33358142695701,10.33358142695701,52.82410524031706,7.546300748616723,73.6
The Barrens,6: 16-18,10,7,13400,103.06424213810307,104.3627890079809,265.62525869569134,26.562525869569136,50.4
The Barrens,7: 19-21,8,2,11075,22.51985498435451,23.532970291451754,46.05282527580626,15.350941758602088,240.5
The Deadmines,6: 16-18,3,0,3950,0.0,0.0,0.0,,inf
The Deadmines,7: 19-21,2,0,3100,0.0,0.0,0.0,,inf
The Hall of Thanes,5: 13-15,2,0,2400,0.0,0.0,0.0,,inf
The Hall of Thanes,6: 16-18,1,0,1450,0.0,0.0,0.0,,inf
Uncategorized,1: 1-3,24,0,1100,0.0,0.0,0.0,,inf
Uncategorized,2: 4-6,70,0,20530,0.0,0.0,0.0,,inf
Uncategorized,3: 7-9,25,0,11665,0.0,0.0,0.0,,inf
Uncategorized,4: 10-12,34,0,19360,0.0,0.0,0.0,,inf
Uncategorized,5: 13-15,8,0,3590,0.0,0.0,0.0,,inf
Uncategorized,7: 19-21,14,0,2170,0.0,0.0,0.0,,inf
Wailing Caverns,6: 16-18,3,0,4300,0.0,0.0,0.0,,inf
Wailing Caverns,7: 19-21,1,0,2050,0.0,0.0,0.0,,inf
Warlock,1: 1-3,2,0,80,0.0,0.0,0.0,,inf
Warlock,2: 4-6,2,0,710,0.0,0.0,0.0,,inf
Warlock,4: 10-12,4,0,2100,0.0,0.0,0.0,,inf
Warlock,7: 19-21,6,0,6570,0.0,0.0,0.0,,inf
Warrior,1: 1-3,6,0,285,0.0,0.0,0.0,,inf
Warrior,4: 10-12,17,0,8280,0.0,0.0,0.0,,inf
Warrior,7: 19-21,1,0,155,0.0,0.0,0.0,,inf
Warsong Gulch,7: 19-21,2,0,2180,0.0,0.0,0.0,,inf
Westfall,3: 7-9,1,0,590,0.0,0.0,0.0,,inf
Westfall,4: 10-12,13,3,7420,51.317162056350654,51.317162056350654,135.78743121699839,16.973428902124798,54.6
Westfall,5: 13-15,16,8,11455,146.9468925585264,146.9468925585264,293.8937851170528,26.717616828822983,39.0
Westfall,6: 16-18,20,4,16445,141.15163730169672,141.15163730169672,391.20624027019295,39.120624027019296,42.0
Westfall,7: 19-21,3,2,3390,46.411668147220254,46.411668147220254,92.82333629444051,46.411668147220254,36.5
Wetlands,7: 19-21,8,2,6205,26.648894532194912,70.57294012382732,146.96995432149998,48.98998477383333,42.2
Winter Veil,1: 1-3,4,0,10,0.0,0.0,0.0,,inf
Winter Veil,4: 10-12,1,0,0,0.0,0.0,0.0,,
Winter Veil,7: 19-21,1,0,0,0.0,0.0,0.0,,
Zephras Isle,1: 1-3,13,4,1475,14.526626524171727,14.526626524171727,41.88673899354562,3.2220568456573555,35.2
Zephras Isle,2: 4-6,27,8,8110,35.71619407858668,35.71619407858668,184.01279337048854,6.815288643351427,44.1
Zephras Isle,3: 7-9,25,14,11665,113.71564109511881,114.56678585476223,346.74531378109714,13.869812551243886,33.6
Zephras Isle,4: 10-12,44,20,26290,224.09935785103906,187.10360409621305,623.7704107429465,14.50628862192899,42.1
Zephras Isle,5: 13-15,8,4,3590,64.72751277403864,64.72751277403864,132.76863385323347,22.12810564220558,27.0
1 zone bracket quests with_objective total_xp total_to_objective total_from_objective total_travel avg_travel xp_per_travel
2 Alchemy 3: 7-9 1 0 700 0.0 0.0 0.0 inf
3 Anvilmar 2: 4-6 1 0 405 0.0 0.0 0.0 inf
4 Ashenvale 6: 16-18 5 0 4910 0.0 0.0 0.0 inf
5 Ashenvale 7: 19-21 16 4 17250 43.48682271513522 73.4378044700066 169.29512805417096 14.10792733784758 101.9
6 Blackrock Depths 1: 1-3 1 0 0 0.0 0.0 0.0
7 Blacksmithing 4: 10-12 1 0 910 0.0 0.0 0.0 inf
8 Blacksmithing 6: 16-18 1 0 1150 0.0 0.0 0.0 inf
9 Cooking 3: 7-9 2 0 1260 0.0 0.0 0.0 inf
10 Cooking 4: 10-12 1 0 910 0.0 0.0 0.0 inf
11 Darkmoon Faire 1: 1-3 1 0 0 0.0 0.0 0.0
12 Darkshore 1: 1-3 2 0 0 0.0 0.0 0.0 0.0
13 Darkshore 4: 10-12 13 6 9440 55.25260876595058 71.74918457229674 134.89102650757374 13.489102650757374 70.0
14 Darkshore 5: 13-15 25 6 18165 89.73167339632545 95.09239954545124 313.72205966930886 14.939145698538518 57.9
15 Darkshore 6: 16-18 24 8 25055 127.9146094434045 145.73004621768075 389.068006383087 22.886353316652176 64.4
16 Darkshore 7: 19-21 20 6 22680 159.5844363435948 159.5844363435948 608.4263538107444 46.80202721621111 37.3
17 Darnassus 4: 10-12 33 1 12680 63.82383333355096 63.82383333355096 268.18045603234043 17.878697068822696 47.3
18 Darnassus 5: 13-15 3 0 1570 0.0 0.0 0.0 inf
19 Deeprun Tram 4: 10-12 2 0 1000 0.0 0.0 0.0 inf
20 Druid 1: 1-3 2 0 125 0.0 0.0 0.0 inf
21 Druid 4: 10-12 11 0 4540 0.0 0.0 0.0 inf
22 Druid 5: 13-15 5 0 3300 0.0 0.0 0.0 inf
23 Druid 6: 16-18 4 0 3025 0.0 0.0 0.0 inf
24 Druid 7: 19-21 8 0 5225 0.0 0.0 0.0 inf
25 Dun Morogh 1: 1-3 19 3 1390 23.253891449566066 23.253891449566066 101.82856998072529 5.989915881219135 13.7
26 Dun Morogh 2: 4-6 32 7 9720 50.08524493955694 50.08524493955694 313.19743687280356 11.18562274545727 31.0
27 Dun Morogh 3: 7-9 20 9 10015 68.86140632021898 68.86140632021898 216.3254391975046 10.81627195987523 46.3
28 Dun Morogh 4: 10-12 36 9 17170 68.3171822128801 76.85951191462325 302.2427492562249 16.79126384756805 56.8
29 Dun Morogh 5: 13-15 7 3 5930 15.756432626061837 12.79481144839578 42.78613417088434 14.262044723628113 138.6
30 Dun Morogh 6: 16-18 3 0 2715 0.0 0.0 0.0 inf
31 Durotar 3: 7-9 1 0 780 0.0 0.0 0.0 0.0 inf
32 Durotar 4: 10-12 1 1 880 6.230228281888587 6.230228281888587 12.460456563777173 12.460456563777173 70.6
33 Duskwood 7: 19-21 6 3 4840 92.75232931209018 92.75232931209018 356.5280738259673 59.42134563766121 13.6
34 Dustwallow Marsh 7: 19-21 1 0 0 0.0 0.0 0.0
35 Elwynn Forest 1: 1-3 21 6 1615 69.79003749304204 78.46992582193789 174.62618608517332 9.190851899219648 9.2
36 Elwynn Forest 2: 4-6 34 7 8960 69.23047417066707 75.42530613926694 384.3753377194917 12.39920444256425 23.3
37 Elwynn Forest 3: 7-9 21 11 12720 177.34875083455742 166.88813990157476 382.9310664541583 19.146553322707916 33.2
38 Elwynn Forest 4: 10-12 37 7 19445 95.9792603985216 109.5204005260423 368.0893413128177 16.003884404905115 52.8
39 Fishing 4: 10-12 2 0 1365 0.0 0.0 0.0 inf
40 Herbalism 6: 16-18 1 0 1150 0.0 0.0 0.0 inf
41 Hunter 1: 1-3 3 0 165 0.0 0.0 0.0 inf
42 Hunter 4: 10-12 23 0 12480 0.0 0.0 0.0 inf
43 Ironforge 1: 1-3 5 0 55 0.0 0.0 44.01942177022199 11.004855442555497 1.2
44 Ironforge 4: 10-12 37 0 13095 0.0 0.0 139.0138443929029 10.693372645607916 94.2
45 Ironforge 5: 13-15 13 1 7505 49.48878485601215 49.48878485601215 166.74611077858142 27.791018463096904 45.0
46 Kharanos 4: 10-12 1 0 85 0.0 0.0 0.0 inf
47 Leatherworking 6: 16-18 1 0 1350 0.0 0.0 0.0 inf
48 Loch Modan 4: 10-12 13 4 8315 72.13647553589269 72.13647553589269 178.71534910648603 29.78589151774767 46.5
49 Loch Modan 5: 13-15 19 4 14375 97.86979000880527 97.86979000880527 301.86249093510827 23.220191610392945 47.6
50 Loch Modan 6: 16-18 14 7 14620 111.3865260164705 126.71951597741982 296.500435444576 26.954585040416 49.3
51 Loch Modan 7: 19-21 9 2 9825 86.69187657745643 88.34718184137823 229.88051265536777 32.84007323648111 42.7
52 Mage 1: 1-3 3 0 165 0.0 0.0 0.0 inf
53 Mage 4: 10-12 6 0 3445 0.0 0.0 0.0 inf
54 Mage 5: 13-15 3 0 1955 0.0 0.0 0.0 inf
55 Mage 6: 16-18 1 0 1150 0.0 0.0 0.0 inf
56 Mage 7: 19-21 1 0 0 0.0 0.0 0.0
57 Miscellaneous 2: 4-6 1 0 405 0.0 0.0 0.0 inf
58 Miscellaneous 4: 10-12 2 0 1260 0.0 0.0 0.0 inf
59 Miscellaneous 7: 19-21 1 0 0 0.0 0.0 0.0
60 Paladin 1: 1-3 2 0 80 0.0 0.0 0.0 inf
61 Paladin 4: 10-12 13 0 1945 0.0 0.0 0.0 inf
62 Paladin 5: 13-15 12 0 7300 0.0 0.0 0.0 inf
63 Paladin 7: 19-21 4 0 1220 0.0 0.0 0.0 inf
64 Priest 1: 1-3 4 0 160 0.0 0.0 0.0 inf
65 Priest 2: 4-6 6 0 1080 0.0 0.0 0.0 inf
66 Priest 4: 10-12 28 0 4620 0.0 0.0 0.0 inf
67 Priest 5: 13-15 7 0 1620 0.0 0.0 0.0 inf
68 Priest 7: 19-21 13 0 3510 0.0 0.0 0.0 inf
69 Redridge Mountains 5: 13-15 10 0 5055 0.0 0.0 56.173623446759436 11.234724689351888 90.0
70 Redridge Mountains 6: 16-18 14 4 11775 63.97863749901971 63.97863749901971 145.4226604472326 24.237110074538766 81.0
71 Redridge Mountains 7: 19-21 10 6 13405 130.85022762249795 130.85022762249795 261.7004552449959 32.71255690562449 51.2
72 Rogue 1: 1-3 5 0 245 0.0 0.0 0.0 inf
73 Rogue 4: 10-12 7 0 4410 0.0 0.0 0.0 inf
74 Rogue 6: 16-18 6 0 1155 0.0 0.0 0.0 inf
75 Rogue 7: 19-21 5 0 3245 0.0 0.0 0.0 inf
76 Ruins of Lordaeron 7: 19-21 1 0 1650 0.0 0.0 0.0 inf
77 Shaman 1: 1-3 1 0 40 0.0 0.0 0.0 inf
78 Shaman 2: 4-6 4 0 985 0.0 0.0 0.0 inf
79 Shaman 4: 10-12 3 0 1500 0.0 0.0 0.0 inf
80 Shaman 5: 13-15 4 0 3480 0.0 0.0 0.0 inf
81 Shaman 7: 19-21 2 0 780 0.0 0.0 0.0 inf
82 Stonetalon Mountains 7: 19-21 12 2 12160 28.785210207029003 28.785210207029003 57.99468448276993 19.331561494256643 209.7
83 Stormwind City 1: 1-3 5 0 355 0.0 0.0 37.31255221936733 9.328138054841833 9.5
84 Stormwind City 4: 10-12 41 1 12405 2.6534505836740276 2.6534505836740276 94.1507884354622 6.276719229030814 131.8
85 Stormwind City 5: 13-15 21 3 13420 72.95021870001224 69.27331129740946 233.74537567420907 23.37453756742091 57.4
86 Stormwind City 6: 16-18 18 0 8530 0.0 0.0 209.3126255632573 29.901803651893903 40.8
87 Teldrassil 1: 1-3 12 3 1200 12.911301433847976 12.911301433847976 51.55593562478292 4.686903238616629 23.3
88 Teldrassil 2: 4-6 33 4 9875 37.75323029138802 37.75323029138802 292.4724855000118 10.832314277778217 33.8
89 Teldrassil 3: 7-9 20 9 11765 95.18597364882127 95.18597364882127 263.9455842809509 15.526210840055937 44.6
90 Teldrassil 4: 10-12 38 5 18245 57.63840505556519 57.63840505556519 218.74146445318962 12.152303580732756 83.4
91 The Barrens 5: 13-15 7 3 3890 10.33358142695701 10.33358142695701 52.82410524031706 7.546300748616723 73.6
92 The Barrens 6: 16-18 10 7 13400 103.06424213810307 104.3627890079809 265.62525869569134 26.562525869569136 50.4
93 The Barrens 7: 19-21 8 2 11075 22.51985498435451 23.532970291451754 46.05282527580626 15.350941758602088 240.5
94 The Deadmines 6: 16-18 3 0 3950 0.0 0.0 0.0 inf
95 The Deadmines 7: 19-21 2 0 3100 0.0 0.0 0.0 inf
96 The Hall of Thanes 5: 13-15 2 0 2400 0.0 0.0 0.0 inf
97 The Hall of Thanes 6: 16-18 1 0 1450 0.0 0.0 0.0 inf
98 Uncategorized 1: 1-3 24 0 1100 0.0 0.0 0.0 inf
99 Uncategorized 2: 4-6 70 0 20530 0.0 0.0 0.0 inf
100 Uncategorized 3: 7-9 25 0 11665 0.0 0.0 0.0 inf
101 Uncategorized 4: 10-12 34 0 19360 0.0 0.0 0.0 inf
102 Uncategorized 5: 13-15 8 0 3590 0.0 0.0 0.0 inf
103 Uncategorized 7: 19-21 14 0 2170 0.0 0.0 0.0 inf
104 Wailing Caverns 6: 16-18 3 0 4300 0.0 0.0 0.0 inf
105 Wailing Caverns 7: 19-21 1 0 2050 0.0 0.0 0.0 inf
106 Warlock 1: 1-3 2 0 80 0.0 0.0 0.0 inf
107 Warlock 2: 4-6 2 0 710 0.0 0.0 0.0 inf
108 Warlock 4: 10-12 4 0 2100 0.0 0.0 0.0 inf
109 Warlock 7: 19-21 6 0 6570 0.0 0.0 0.0 inf
110 Warrior 1: 1-3 6 0 285 0.0 0.0 0.0 inf
111 Warrior 4: 10-12 17 0 8280 0.0 0.0 0.0 inf
112 Warrior 7: 19-21 1 0 155 0.0 0.0 0.0 inf
113 Warsong Gulch 7: 19-21 2 0 2180 0.0 0.0 0.0 inf
114 Westfall 3: 7-9 1 0 590 0.0 0.0 0.0 inf
115 Westfall 4: 10-12 13 3 7420 51.317162056350654 51.317162056350654 135.78743121699839 16.973428902124798 54.6
116 Westfall 5: 13-15 16 8 11455 146.9468925585264 146.9468925585264 293.8937851170528 26.717616828822983 39.0
117 Westfall 6: 16-18 20 4 16445 141.15163730169672 141.15163730169672 391.20624027019295 39.120624027019296 42.0
118 Westfall 7: 19-21 3 2 3390 46.411668147220254 46.411668147220254 92.82333629444051 46.411668147220254 36.5
119 Wetlands 7: 19-21 8 2 6205 26.648894532194912 70.57294012382732 146.96995432149998 48.98998477383333 42.2
120 Winter Veil 1: 1-3 4 0 10 0.0 0.0 0.0 inf
121 Winter Veil 4: 10-12 1 0 0 0.0 0.0 0.0
122 Winter Veil 7: 19-21 1 0 0 0.0 0.0 0.0
123 Zephras Isle 1: 1-3 13 4 1475 14.526626524171727 14.526626524171727 41.88673899354562 3.2220568456573555 35.2
124 Zephras Isle 2: 4-6 27 8 8110 35.71619407858668 35.71619407858668 184.01279337048854 6.815288643351427 44.1
125 Zephras Isle 3: 7-9 25 14 11665 113.71564109511881 114.56678585476223 346.74531378109714 13.869812551243886 33.6
126 Zephras Isle 4: 10-12 44 20 26290 224.09935785103906 187.10360409621305 623.7704107429465 14.50628862192899 42.1
127 Zephras Isle 5: 13-15 8 4 3590 64.72751277403864 64.72751277403864 132.76863385323347 22.12810564220558 27.0

View File

@@ -0,0 +1,3 @@
pandas
numpy
sklearn

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,43 @@
zone,continent,area_km2,area_sq_mi,estimate,notes
The Barrens,Kalimdor,12.3,4.8,False,
Tanaris,Kalimdor,10.5,4.0,False,
Winterspring,Kalimdor,9.8,3.8,False,
Feralas,Kalimdor,7.8,3.0,False,
Silithus,Kalimdor,6.2,2.4,False,
Un'Goro Crater,Kalimdor,5.1,2.0,False,
Azshara,Kalimdor,4.9,1.9,False,
Ashenvale,Kalimdor,4.8,1.8,False,
Mulgore,Kalimdor,4.6,1.7,False,
Durotar,Kalimdor,4.2,1.6,False,
Desolace,Kalimdor,4.1,1.5,False,
Dustwallow Marsh,Kalimdor,3.9,1.5,False,
Thousand Needles,Kalimdor,3.8,1.4,False,
Felwood,Kalimdor,3.7,1.4,False,
Stonetalon Mountains,Kalimdor,3.5,1.3,False,
Darkshore,Kalimdor,3.3,1.2,False,
Teldrassil,Kalimdor,2.8,1.1,False,
Moonglade,Kalimdor,1.3,0.5,False,
Mount Hyjal,Kalimdor,3.6,1.4,True,Forever zone; level 60
Shen'dralas,Kalimdor,3.0,1.2,True,Forever zone
Eastern Plaguelands,Eastern Kingdoms,5.6,2.2,False,
Wetlands,Eastern Kingdoms,4.4,1.7,False,
Stranglethorn Vale,Eastern Kingdoms,4.0,1.5,False,
Tirisfal Glades,Eastern Kingdoms,3.7,1.4,False,
Western Plaguelands,Eastern Kingdoms,3.4,1.3,False,
Elwynn Forest,Eastern Kingdoms,3.3,1.2,False,
The Hinterlands,Eastern Kingdoms,3.2,1.2,False,
Arathi Highlands,Eastern Kingdoms,2.9,1.1,False,
Alterac Mountains & Hillsbrad Foothills,Eastern Kingdoms,2.8,1.1,False,combined; separate zones in quest data
Westfall,Eastern Kingdoms,2.6,1.0,False,
Duskwood,Eastern Kingdoms,2.5,0.96,False,
Loch Modan,Eastern Kingdoms,2.4,0.9,False,
Silverpine Forest,Eastern Kingdoms,2.3,0.8,False,
Badlands,Eastern Kingdoms,2.1,0.8,False,
Swamp of Sorrows,Eastern Kingdoms,2.0,0.7,False,
Burning Steppes,Eastern Kingdoms,1.9,0.7,False,
Redridge Mountains,Eastern Kingdoms,1.8,0.7,False,
Blasted Lands,Eastern Kingdoms,1.7,0.6,False,
Searing Gorge,Eastern Kingdoms,1.3,0.5,False,
Deadwind Pass,Eastern Kingdoms,1.0,0.4,False,upper bound (< 1.0)
Riverglades,Eastern Kingdoms,4.0,1.5,True,Forever zone; levels 35-45
Zephras Isle,Skywall,2.5,0.97,True,Forever zone; Skyborne start 1-12
1 zone continent area_km2 area_sq_mi estimate notes
2 The Barrens Kalimdor 12.3 4.8 False
3 Tanaris Kalimdor 10.5 4.0 False
4 Winterspring Kalimdor 9.8 3.8 False
5 Feralas Kalimdor 7.8 3.0 False
6 Silithus Kalimdor 6.2 2.4 False
7 Un'Goro Crater Kalimdor 5.1 2.0 False
8 Azshara Kalimdor 4.9 1.9 False
9 Ashenvale Kalimdor 4.8 1.8 False
10 Mulgore Kalimdor 4.6 1.7 False
11 Durotar Kalimdor 4.2 1.6 False
12 Desolace Kalimdor 4.1 1.5 False
13 Dustwallow Marsh Kalimdor 3.9 1.5 False
14 Thousand Needles Kalimdor 3.8 1.4 False
15 Felwood Kalimdor 3.7 1.4 False
16 Stonetalon Mountains Kalimdor 3.5 1.3 False
17 Darkshore Kalimdor 3.3 1.2 False
18 Teldrassil Kalimdor 2.8 1.1 False
19 Moonglade Kalimdor 1.3 0.5 False
20 Mount Hyjal Kalimdor 3.6 1.4 True Forever zone; level 60
21 Shen'dralas Kalimdor 3.0 1.2 True Forever zone
22 Eastern Plaguelands Eastern Kingdoms 5.6 2.2 False
23 Wetlands Eastern Kingdoms 4.4 1.7 False
24 Stranglethorn Vale Eastern Kingdoms 4.0 1.5 False
25 Tirisfal Glades Eastern Kingdoms 3.7 1.4 False
26 Western Plaguelands Eastern Kingdoms 3.4 1.3 False
27 Elwynn Forest Eastern Kingdoms 3.3 1.2 False
28 The Hinterlands Eastern Kingdoms 3.2 1.2 False
29 Arathi Highlands Eastern Kingdoms 2.9 1.1 False
30 Alterac Mountains & Hillsbrad Foothills Eastern Kingdoms 2.8 1.1 False combined; separate zones in quest data
31 Westfall Eastern Kingdoms 2.6 1.0 False
32 Duskwood Eastern Kingdoms 2.5 0.96 False
33 Loch Modan Eastern Kingdoms 2.4 0.9 False
34 Silverpine Forest Eastern Kingdoms 2.3 0.8 False
35 Badlands Eastern Kingdoms 2.1 0.8 False
36 Swamp of Sorrows Eastern Kingdoms 2.0 0.7 False
37 Burning Steppes Eastern Kingdoms 1.9 0.7 False
38 Redridge Mountains Eastern Kingdoms 1.8 0.7 False
39 Blasted Lands Eastern Kingdoms 1.7 0.6 False
40 Searing Gorge Eastern Kingdoms 1.3 0.5 False
41 Deadwind Pass Eastern Kingdoms 1.0 0.4 False upper bound (< 1.0)
42 Riverglades Eastern Kingdoms 4.0 1.5 True Forever zone; levels 35-45
43 Zephras Isle Skywall 2.5 0.97 True Forever zone; Skyborne start 1-12

8
wowhead_scraper/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
**/*.html
**/*.json
output/
.idea/
venv/
**/__pycache__
!tests/fixtures/*.html
.pytest_cache/

21
wowhead_scraper/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Björn Böing
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

71
wowhead_scraper/README.md Normal file
View File

@@ -0,0 +1,71 @@
# Wowhead Scraper
Scrapes quests and objects from [Wowhead](https://www.wowhead.com/forever) in two stages:
1. **List**: crawls the list pages and writes one CSV row per entity (`output/quests.csv`, `output/objects.csv`).
2. **Detail**: visits each row's page and appends one JSON object per line (`output/quests.jsonl`, `output/objects.jsonl`).
## Setup
```
python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt
playwright install chromium
```
Wowhead is behind an AWS WAF JavaScript challenge. Requests go through `curl_cffi` with a Chrome
fingerprint; when the challenge appears, headless Chromium solves it once and its cookie is reused.
## Usage
```
python runner.py list quest # -> output/quests.csv
python runner.py detail quest # -> output/quests.jsonl
python runner.py all object # both stages
python runner.py detail quest --ids 166 98028
python runner.py detail quest --limit 50
python runner.py find-missing quest # re-fetch quests skipped during a detail run
python runner.py find-missing quest --dry-run # only list them
```
`find-missing` only touches rows *below* the highest ID already in the JSONL, i.e. ones a detail run
skipped (usually because a temporary block outlasted the retries). Rows past that point haven't been
reached yet; `detail` continues with those.
The detail stage skips IDs already in the JSONL file, so an interrupted run (or one with failures)
resumes where it left off when rerun. Pages Wowhead doesn't have are written as `{"id": ..., "not_found": true}`.
| Option | Default | Description |
|-----------------|-----------|---------------------------------------------------------|
| `-v, --version` | `forever` | Wowhead path prefix |
| `--delay` | `1.0` | Minimum seconds between requests (across all workers) |
| `--jitter` | `0.5` | Extra random delay, 0..N seconds |
| `--concurrency` | `2` | Parallel detail workers |
| `--max-retries` | `5` | Retries on 403/429/5xx/network errors |
| `--backoff` | `2.0` | First retry wait in seconds; doubles each retry, max 120 |
## How list retrieval works
A list page holds at most 1000 rows (the `#200` style pagination is client side only), so the list
stage requests ID ranges with Wowhead's ID filter and splits any range whose result was truncated.
A full quest or object list takes about 30 requests. The final count is checked against Wowhead's
own "N found" total.
## Layout
| Path | Contents |
|-----------------|------------------------------------------------------------|
| `config.py` | All URLs, filter ids and crawl settings |
| `fetcher.py` | HTTP client: throttle, back-off, WAF handling |
| `parsers/` | Pure functions from page HTML to dicts, one module per type |
| `spiders/` | `Spider` base class with both stages; thin subclasses per type |
| `tests/` | Parser tests against `example_pages/` and `tests/fixtures/` |
### Adding a type (e.g. items)
1. Add an `EntityConfig` to `ENTITIES` in `config.py` (the "ID" filter id is in `Filter.init` on the list page).
2. Write `parsers/item.py` with `LISTVIEW_ID`, `CSV_FIELDS`, `list_row()` and `parse_page()`.
3. Add a `Spider` subclass in `spiders/` and register it in `SPIDERS`.
Run the tests with `pytest`.

53
wowhead_scraper/config.py Normal file
View File

@@ -0,0 +1,53 @@
"""All URL and crawl configuration lives here."""
from dataclasses import dataclass
from pathlib import Path
BASE_DIR = Path(__file__).parent
OUTPUT_DIR = BASE_DIR / "output"
BASE_URL = "https://www.wowhead.com"
GAME_VERSION = "forever"
# Wowhead list pages never return more than this many rows per request.
LIST_ROW_CAP = 1000
@dataclass(frozen=True)
class EntityConfig:
name: str # "quest"
list_path: str # path of the list page, e.g. "quests"
id_filter: int # Wowhead filter criterion id for "ID"
max_id: int # upper bound for the ID-range bisection
def list_url(self, lo: int, hi: int, version: str = GAME_VERSION) -> str:
# filter=<criteria>;<operators>;<values> operators: 2 is ">=", 4 is "<="
f = self.id_filter
return f"{BASE_URL}/{version}/{self.list_path}?filter={f}:{f};2:4;{lo}:{hi}"
def detail_url(self, entity_id: int, version: str = GAME_VERSION) -> str:
return f"{BASE_URL}/{version}/{self.name}={entity_id}"
def list_csv(self) -> Path:
return OUTPUT_DIR / f"{self.list_path}.csv"
def detail_jsonl(self) -> Path:
return OUTPUT_DIR / f"{self.list_path}.jsonl"
ENTITIES = {
"quest": EntityConfig(name="quest", list_path="quests", id_filter=30, max_id=2_000_000),
"object": EntityConfig(name="object", list_path="objects", id_filter=15, max_id=2_000_000),
# To add items: look up the "ID" filter id in Filter.init on the /items list page.
}
@dataclass
class CrawlSettings:
delay: float = 1.0 # minimum seconds between requests (shared across workers)
jitter: float = 2.5 # extra random delay, 0..jitter seconds
concurrency: int = 2 # parallel workers
timeout: float = 30.0
max_retries: int = 5
backoff_base: float = 2.0 # retry n waits backoff_base * 2**n seconds (capped)
backoff_max: float = 120.0
impersonate: str = "chrome" # curl_cffi browser fingerprint

121
wowhead_scraper/fetcher.py Normal file
View File

@@ -0,0 +1,121 @@
"""HTTP client: browser-impersonating requests with a shared throttle, back-off and WAF handling.
Wowhead sits behind an AWS WAF JavaScript challenge. curl_cffi alone can't solve it, so when a
challenge page comes back we load the page once in headless Chromium (Playwright), take the
`aws-waf-token` cookie it earns, and keep using the fast curl_cffi client with that cookie.
"""
import logging
import random
import threading
import time
from curl_cffi import requests
from curl_cffi.requests.exceptions import RequestException
from config import CrawlSettings
log = logging.getLogger(__name__)
# Playwright's default UA says "HeadlessChrome", which CloudFront rejects outright.
BROWSER_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36")
RETRY_STATUSES = {403, 408, 429, 500, 502, 503, 504}
class FetchError(Exception):
pass
class NotFound(FetchError):
pass
def is_challenge(html: str) -> bool:
# Real pages also set awsWafCookieDomainList, so look for the challenge loader itself.
return "AwsWafIntegration" in html and "challenge.js" in html
class Fetcher:
def __init__(self, settings: CrawlSettings = None) -> None:
self.settings = settings or CrawlSettings()
self._local = threading.local()
self._throttle_lock = threading.Lock()
self._next_request_at = 0.0
self._waf_lock = threading.Lock()
self._cookies: dict = {}
self._cookie_generation = 0
def get(self, url: str) -> str:
s = self.settings
for attempt in range(s.max_retries + 1):
generation = self._cookie_generation
self._throttle()
try:
r = self._session().get(url, cookies=self._cookies, timeout=s.timeout)
status, text = r.status_code, r.text
except RequestException as e:
status, text, reason = None, "", f"{type(e).__name__}: {e}"
else:
if "notFound=" in r.url or status == 404:
raise NotFound(url)
if is_challenge(text):
self._solve_challenge(url, generation)
continue
if status == 200:
return text
if status not in RETRY_STATUSES:
raise FetchError(f"HTTP {status} for {url}")
reason = f"HTTP {status}"
if attempt == s.max_retries:
break
wait = min(s.backoff_max, s.backoff_base * 2 ** attempt) + random.uniform(0, s.jitter)
log.warning("%s for %s, retry %d/%d in %.1fs", reason, url, attempt + 1, s.max_retries, wait)
time.sleep(wait)
raise FetchError(f"Giving up on {url} after {s.max_retries} retries")
def _session(self) -> requests.Session:
# curl_cffi sessions aren't thread-safe, so each worker thread gets its own.
if not hasattr(self._local, "session"):
self._local.session = requests.Session(impersonate=self.settings.impersonate)
return self._local.session
def _throttle(self) -> None:
with self._throttle_lock:
now = time.monotonic()
start_at = max(now, self._next_request_at)
self._next_request_at = start_at + self.settings.delay + random.uniform(0, self.settings.jitter)
time.sleep(start_at - now)
def _solve_challenge(self, url: str, seen_generation: int) -> None:
with self._waf_lock:
if self._cookie_generation != seen_generation:
return # another worker refreshed the token while we waited
log.info("WAF challenge hit, solving it in headless Chromium")
self._cookies = solve_waf_challenge(url, self.settings.timeout)
self._cookie_generation += 1
def solve_waf_challenge(url: str, timeout: float) -> dict:
from playwright.sync_api import Error as PlaywrightError, sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--disable-blink-features=AutomationControlled"])
try:
context = browser.new_context(user_agent=BROWSER_UA, locale="en-US")
page = context.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
deadline = time.monotonic() + timeout
# The challenge script sets the cookie, then reloads into the real page.
while time.monotonic() < deadline:
cookies = {c["name"]: c["value"] for c in context.cookies()}
try:
solved = "aws-waf-token" in cookies and not is_challenge(page.content())
except PlaywrightError: # page is mid-reload
solved = False
if solved:
return cookies
page.wait_for_timeout(500)
finally:
browser.close()
raise FetchError(f"Could not solve WAF challenge for {url}")

View File

View File

@@ -0,0 +1,142 @@
"""Extractors for the data blobs Wowhead embeds in its pages.
Almost everything worth having is in inline JSON / JS object literals rather than in the HTML,
so these helpers find a blob by its marker and hand it to chompjs (which accepts unquoted keys
and bare identifiers that plain json.loads would reject).
"""
import json
import re
from typing import Any, Dict, List, Optional, Tuple
import chompjs
from parsel import Selector
from config import LIST_ROW_CAP
SIDES = {1: "Alliance", 2: "Horde", 3: "Both"}
_GATHERER_TYPES = {1: "npc", 2: "object", 3: "item", 5: "quest"}
_ENTITY_LINK = re.compile(r"\[url=/\w+/(npc|object|item|quest|zone)=(\d+)[^\]]*\](.*?)\[/url\]")
_MARKUP_TAG = re.compile(r"\[/?[a-z]+[^\]]*\]")
_SMALL_WORDS = {"a", "an", "and", "at", "for", "in", "of", "on", "the", "to"}
def js_object_after(html: str, marker: str) -> Optional[Any]:
"""Parse the JS object/array literal that starts right after `marker`."""
i = html.find(marker)
if i < 0:
return None
return chompjs.parse_js_object(html[i + len(marker):])
def json_script(html: str, script_id: str) -> Optional[Any]:
m = re.search(rf'<script type="application/json" id="{re.escape(script_id)}">(.*?)</script>', html, re.S)
return json.loads(m.group(1)) if m else None
def listview(html: str, listview_id: str) -> Optional[Dict[str, Any]]:
"""Return a `new Listview({...})` config by id. List pages may use either embedding style."""
for m in re.finditer(r"new Listview\(\{", html):
head = html[m.end():m.end() + 300]
if re.search(rf"\bid:\s*'{re.escape(listview_id)}'", head):
return chompjs.parse_js_object(html[m.end() - 1:])
for lv in json_script(html, "data.page.listPage.listviews") or []:
if lv.get("id") == listview_id:
return lv
return None
def filter_categories(html: str) -> Dict[int, Tuple[str, Dict[int, str]]]:
"""Category tree from the list page's Filter.init config: {id: (name, {sub_id: sub_name})}.
Top-level and sub-category ids overlap (Kalimdor and Dun Morogh are both 1), hence the nesting.
Names come from url slugs, so "elwynn-forest" becomes "Elwynn Forest".
"""
config = js_object_after(html, "Filter.init(") or {}
def name(c):
words = c["url"].split("-")
return " ".join(w if i and w in _SMALL_WORDS else w.capitalize() for i, w in enumerate(words))
return {int(c["id"]): (name(c), {int(s["id"]): name(s) for s in c.get("categories") or []})
for c in config.get("categories") or []}
def list_page(html: str, listview_id: str) -> Tuple[List[Dict[str, Any]], bool, Optional[int]]:
"""Rows of a list page, whether Wowhead truncated them at its row cap, and its total match count."""
lv = listview(html, listview_id) or {}
rows = lv.get("data") or []
truncated = bool(lv.get("_truncated")) or len(rows) >= LIST_ROW_CAP
# "5,442 quests found (1,000 displayed)" only appears when truncated
m = re.match(r"([\d,]+) \w+ found", lv.get("note") or "")
total = lv.get("totalCount") or (int(m.group(1).replace(",", "")) if m else None)
return rows, truncated, total if truncated else len(rows)
def entity_blob(html: str, kind: str, entity_id: int) -> Optional[Dict[str, Any]]:
"""The `$.extend(g_quests[123], {...})` record a detail page carries for itself."""
return js_object_after(html, f"$.extend(g_{kind}s[{entity_id}], ")
def gatherer(html: str) -> Dict[str, Dict[int, str]]:
"""Names of every entity the page references, keyed by type then id."""
names: Dict[str, Dict[int, str]] = {}
for m in re.finditer(r"WH\.Gatherer\.addData\((\d+), \d+, ", html):
kind = _GATHERER_TYPES.get(int(m.group(1)))
if kind:
for eid, data in chompjs.parse_js_object(html[m.end():]).items():
names.setdefault(kind, {})[int(eid)] = data.get("name_enus")
return names
def quick_facts(html: str) -> List[str]:
"""The "Quick Facts" infobox as a list of raw markup lines ([li] items)."""
m = re.search(r'WH\.markup\.printHtml\(("(?:[^"\\]|\\.)*"), "infobox-contents-0"', html)
if not m:
return []
markup = json.loads(m.group(1))
return re.findall(r"\[li\](.*?)\[/li\]", markup)
def markup_text(markup: str) -> str:
return " ".join(_MARKUP_TAG.sub("", markup).replace("&nbsp;", " ").split())
def markup_links(markup: str) -> List[Dict[str, Any]]:
return [{"type": t, "id": int(i), "name": markup_text(n)} for t, i, n in _ENTITY_LINK.findall(markup)]
def mapper(html: str) -> Optional[Dict[str, Any]]:
return js_object_after(html, "new Mapper(")
def selector(html: str) -> Selector:
# Keep <br> line breaks in extracted text.
return Selector(text=re.sub(r"<br\s*/?>", "\n", html))
def text_after(sel: Selector, xpath: str, stop_tags=("h2",)) -> Optional[str]:
"""Text following the first node matching `xpath`, up to the first sibling in `stop_tags`."""
found = sel.xpath(xpath)
if not found:
return None
node = found[0].root
parts = [node.tail or ""]
for sibling in node.itersiblings():
if sibling.tag in stop_tags:
break
if sibling.tag not in ("script", "table"):
parts.append("".join(sibling.itertext()))
parts.append(sibling.tail or "")
return clean_text("".join(parts))
def section_text(sel: Selector, heading: str) -> Optional[str]:
"""Text of an <h2> section such as Description, Progress or Completion."""
return text_after(sel, f"//h2[normalize-space(.)='{heading}']")
def clean_text(text: str) -> Optional[str]:
lines = [" ".join(line.split()) for line in text.splitlines()]
text = "\n".join(lines).strip()
return re.sub(r"\n{3,}", "\n\n", text) or None

View File

@@ -0,0 +1,68 @@
from typing import Any, Dict, List
from parsers.common import SIDES, entity_blob, js_object_after, listview, markup_text, quick_facts
LISTVIEW_ID = "objects"
CSV_FIELDS = ["id", "name", "type", "type_id", "skill", "zone_ids", "url"]
def list_row(raw: Dict[str, Any], categories: Dict[int, tuple]) -> Dict[str, Any]:
type_name, _ = categories.get(raw.get("type"), (None, {}))
return {
"id": raw["id"],
"name": raw.get("name"),
"type": type_name,
"type_id": raw.get("type"),
"skill": raw.get("skill"),
# -1 entries are placeholders for "unknown location"
"zone_ids": ";".join(str(z) for z in raw.get("location", []) if z >= 0),
}
def parse_page(html: str, row: Dict[str, Any]) -> Dict[str, Any]:
object_id = int(row["id"])
blob = entity_blob(html, "object", object_id) or {}
return {
"id": object_id,
"name": blob.get("name", row.get("name")),
"type": row.get("type"),
"type_id": blob.get("type"),
"skill": int(row["skill"]) if row.get("skill") not in (None, "") else None,
"locations": _locations(js_object_after(html, "var g_mapperData = ")),
"starts": _quests(html, "starts"),
"ends": _quests(html, "ends"),
"contains": _loot(html),
"quick_facts": [markup_text(f) for f in quick_facts(html)],
"url": row.get("url"),
}
def _locations(mapper_data) -> List[Dict[str, Any]]:
locations = []
for zone_id, floors in (mapper_data or {}).items():
for floor in floors:
locations.append({
"zone_id": int(zone_id),
"zone": floor.get("uiMapName"),
"ui_map_id": floor.get("uiMapId"),
"count": floor.get("count"),
"coords": floor.get("coords"),
})
return locations
def _quests(html: str, listview_id: str) -> List[Dict[str, Any]]:
lv = listview(html, listview_id) or {}
return [{"id": q["id"], "name": q.get("name"), "level": q.get("level"),
"reqlevel": q.get("reqlevel"), "side": SIDES.get(q.get("side"))}
for q in lv.get("data") or []]
def _loot(html: str) -> List[Dict[str, Any]]:
lv = listview(html, "contains") or {}
opened = lv.get("_totalCount")
return [{"id": i["id"], "name": i.get("name"), "count": i.get("count"),
# same figure Wowhead shows in its "%" column
"percent": round(100 * i["count"] / opened, 2) if opened and i.get("count", -1) > 0 else None,
"stack": i.get("stack")}
for i in lv.get("data") or []]

View File

@@ -0,0 +1,131 @@
import re
from typing import Any, Dict, List
from parsers.common import (SIDES, entity_blob, gatherer, markup_links, markup_text, mapper, quick_facts,
section_text, selector, text_after)
LISTVIEW_ID = "quests"
CSV_FIELDS = ["id", "name", "level", "reqlevel", "side", "category", "category_id",
"category2", "category2_id", "exp", "money", "url"]
_MAPPER_TYPES = {1: "npc", 2: "object", 3: "item"}
def list_row(raw: Dict[str, Any], categories: Dict[int, tuple]) -> Dict[str, Any]:
top_name, sub_names = categories.get(raw.get("category2"), (None, {}))
return {
"id": raw["id"],
"name": raw.get("name"),
"level": raw.get("level"),
"reqlevel": raw.get("reqlevel"),
"side": SIDES.get(raw.get("side")),
"category": sub_names.get(raw.get("category")),
"category_id": raw.get("category"),
"category2": top_name,
"category2_id": raw.get("category2"),
"exp": raw.get("xp"),
"money": raw.get("money"),
}
def parse_page(html: str, row: Dict[str, Any]) -> Dict[str, Any]:
quest_id = int(row["id"])
sel = selector(html)
blob = entity_blob(html, "quest", quest_id) or {}
names = gatherer(html)
facts = quick_facts(html)
return {
"id": quest_id,
"name": blob.get("name", row.get("name")),
"level": blob.get("level"),
"reqlevel": blob.get("reqlevel"),
"side": SIDES.get(blob.get("side")),
"category": row.get("category"),
"category2": row.get("category2"),
"objectives": text_after(sel, "//h1[@class='heading-size-1']", stop_tags=("table", "div", "h2", "script")),
"objective_items": _objective_items(sel),
"description": section_text(sel, "Description"),
"progress": section_text(sel, "Progress"),
"completion": section_text(sel, "Completion"),
"start": _fact_links(facts, "Start:"),
"end": _fact_links(facts, "End:"),
"locations": _locations(mapper(html)),
"series": _series(sel, quest_id),
"rewards": {
"xp": blob.get("xp"),
# the page's record omits money when it is 0
"money": blob.get("money", 0),
"item_choices": _items(blob.get("itemchoices"), names),
"items": _items(blob.get("itemrewards"), names),
"reputation": _reputation(sel, blob.get("reprewards")),
},
"quick_facts": [markup_text(f) for f in facts],
"url": row.get("url"),
}
def _fact_links(facts: List[str], label: str) -> List[Dict[str, Any]]:
for fact in facts:
if markup_text(fact).startswith(label):
return markup_links(fact)
return []
def _locations(mapper_config) -> List[Dict[str, Any]]:
"""Flatten mapper points: who/what is where, and its role (start, end, objective)."""
points = []
for zone_id, zone in ((mapper_config or {}).get("objectives") or {}).items():
for level in zone.get("levels", []):
for p in level:
points.append({
"role": p.get("point"),
"type": _MAPPER_TYPES.get(p.get("type"), p.get("type")),
"id": p.get("id"),
"name": p.get("name"),
"zone_id": int(zone_id),
"zone": zone.get("zone"),
"coord": p.get("coord"),
"coords": p.get("coords"),
})
return points
def _objective_items(sel) -> List[Dict[str, Any]]:
result = []
for tr in sel.xpath("(//table[@class='icon-list'])[1]//tr"):
href = tr.xpath(".//a/@href").get() or ""
m = re.search(r"/(\w+)=(\d+)", href)
# A count for items/kills; a standing such as "Friendly" for faction objectives.
quantity = tr.attrib.get("data-icon-list-quantity", "1")
result.append({
"type": m.group(1) if m else None,
"id": int(m.group(2)) if m else None,
"name": " ".join(tr.xpath(".//a//text()").getall()).strip() or None,
"quantity": int(quantity) if quantity.isdigit() else None,
"standing": None if quantity.isdigit() else quantity,
"provided": "(Provided)" in "".join(tr.xpath(".//td//text()").getall()),
})
return result
def _series(sel, quest_id: int) -> List[Dict[str, Any]]:
series = []
for td in sel.xpath("//table[@class='series']//tr/td"):
href = td.xpath(".//a/@href").get()
m = re.search(r"quest=(\d+)", href or "")
series.append({"id": int(m.group(1)) if m else quest_id,
"name": " ".join(td.xpath(".//text()").getall()).strip()})
return series
def _items(pairs, names) -> List[Dict[str, Any]]:
return [{"id": i, "name": names.get("item", {}).get(i), "count": c} for i, c in pairs or []]
def _reputation(sel, pairs) -> List[Dict[str, Any]]:
factions = {}
for a in sel.xpath("//a[contains(@href, '/faction=')]"):
m = re.search(r"faction=(\d+)", a.attrib["href"])
factions[int(m.group(1))] = a.xpath("string()").get().strip()
return [{"faction_id": f, "faction": factions.get(f), "value": v} for f, v in pairs or []]

View File

@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

View File

@@ -0,0 +1,5 @@
chompjs==1.4.1
curl_cffi==0.17.0
parsel==1.12.1
playwright==1.64.1
pytest==9.1.1

41
wowhead_scraper/runner.py Normal file
View File

@@ -0,0 +1,41 @@
import logging
from argparse import ArgumentParser
from config import GAME_VERSION, CrawlSettings
from fetcher import Fetcher
from spiders import SPIDERS
def main() -> None:
defaults = CrawlSettings()
parser = ArgumentParser(description="Scrape Wowhead in two stages: list -> CSV, then detail -> JSONL.")
parser.add_argument("stage", choices=["list", "detail", "all", "find-missing"])
parser.add_argument("target", choices=sorted(SPIDERS))
parser.add_argument("-v", "--version", default=GAME_VERSION, help=f"Wowhead path prefix. Default: {GAME_VERSION}")
parser.add_argument("--delay", type=float, default=defaults.delay, help="Min seconds between requests")
parser.add_argument("--jitter", type=float, default=defaults.jitter, help="Extra random delay, 0..N seconds")
parser.add_argument("--concurrency", type=int, default=defaults.concurrency, help="Parallel detail workers")
parser.add_argument("--max-retries", type=int, default=defaults.max_retries)
parser.add_argument("--backoff", type=float, default=defaults.backoff_base, help="First retry wait in seconds, doubles each retry")
parser.add_argument("--limit", type=int, help="Detail stage: only fetch this many pages")
parser.add_argument("--ids", type=int, nargs="+", help="Detail stage: only fetch these IDs")
parser.add_argument("--dry-run", action="store_true", help="find-missing: only list the skipped IDs")
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
settings = CrawlSettings(delay=args.delay, jitter=args.jitter, concurrency=args.concurrency,
max_retries=args.max_retries, backoff_base=args.backoff)
spider = SPIDERS[args.target](Fetcher(settings), version=args.version)
if args.stage in ("list", "all"):
spider.crawl_list()
if args.stage in ("detail", "all"):
spider.crawl_details(limit=args.limit, ids=args.ids)
if args.stage == "find-missing":
spider.find_missing(dry_run=args.dry_run)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,7 @@
from .object import ObjectSpider
from .quest import QuestSpider
SPIDERS = {
"quest": QuestSpider,
"object": ObjectSpider,
}

View File

@@ -0,0 +1,153 @@
import csv
import json
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Set
from config import GAME_VERSION, OUTPUT_DIR, EntityConfig
from fetcher import Fetcher, NotFound
from parsers.common import filter_categories, list_page
log = logging.getLogger(__name__)
class Spider:
"""Two-stage crawler. Subclasses only say which entity it is and how to parse it.
Stage 1 (crawl_list): walk the list pages and write one CSV row per entity.
Stage 2 (crawl_details): visit each row's page and append one JSON line per entity.
"""
entity: EntityConfig
listview_id: str
csv_fields: List[str]
def __init__(self, fetcher: Fetcher, version: str = GAME_VERSION) -> None:
self.fetcher = fetcher
self.version = version
def list_row(self, raw: Dict[str, Any], categories: Dict[int, tuple]) -> Dict[str, Any]:
raise NotImplementedError
def parse_page(self, html: str, row: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
def crawl_list(self) -> Path:
"""List pages cap out at 1000 rows, so split the ID range until no response is truncated.
Wowhead silently drops a filter bound above the highest ID it knows (and keeps the other),
so a response is a superset of the range asked for:
- rows below `lo`: the lower bound was dropped, so nothing exists this high; skip it.
- rows above `hi`: the upper bound was dropped; clipping to the range is still exact.
"""
rows: Dict[int, Dict[str, Any]] = {}
expected_total = None
ranges = [(1, self.entity.max_id)]
while ranges:
lo, hi = ranges.pop()
html = self.fetcher.get(self.entity.list_url(lo, hi, self.version))
raw_rows, truncated, total = list_page(html, self.listview_id)
expected_total = expected_total if expected_total is not None else total
if any(r["id"] < lo for r in raw_rows):
log.info("IDs %d-%d are past the highest ID Wowhead filters on, skipping", lo, hi)
continue
raw_rows = [r for r in raw_rows if r["id"] <= hi]
if truncated and lo < hi:
mid = (lo + hi) // 2
ranges += [(mid + 1, hi), (lo, mid)]
log.info("IDs %d-%d truncated, splitting", lo, hi)
continue
categories = filter_categories(html)
for raw in raw_rows:
row = self.list_row(raw, categories)
row["url"] = self.entity.detail_url(row["id"], self.version)
rows[row["id"]] = row
log.info("IDs %d-%d: %d rows (%d total)", lo, hi, len(raw_rows), len(rows))
path = self.entity.list_csv()
OUTPUT_DIR.mkdir(exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=self.csv_fields)
writer.writeheader()
writer.writerows(rows[i] for i in sorted(rows))
log.info("Wrote %d %ss to %s", len(rows), self.entity.name, path)
if expected_total is not None and expected_total != len(rows):
log.warning("Wowhead reports %d %ss in total but %d were listed", expected_total, self.entity.name, len(rows))
return path
def crawl_details(self, limit: Optional[int] = None, ids: Optional[Iterable[int]] = None) -> Path:
"""Fetch detail pages for list rows not yet in the JSONL output, so reruns resume and retry failures."""
rows, done = self._list_rows(), self._done_ids()
wanted = set(ids) if ids else None
todo = [r for r in rows if int(r["id"]) not in done and (wanted is None or int(r["id"]) in wanted)]
todo = todo[:limit] if limit else todo
log.info("%d %ss to fetch (%d already done)", len(todo), self.entity.name, len(done))
return self._fetch_all(todo)
def find_missing(self, dry_run: bool = False) -> List[int]:
"""Re-fetch rows skipped by an earlier detail run (e.g. while blocked).
Rows are fetched in CSV order, so "skipped" means not in the output but below the highest ID
that is. Rows past that point simply haven't been reached yet; `detail` picks those up.
"""
rows, done = self._list_rows(), self._done_ids()
last_done = max(done, default=0)
missing = [r for r in rows if int(r["id"]) not in done and int(r["id"]) < last_done]
not_reached = sum(1 for r in rows if int(r["id"]) > last_done)
missing_ids = [int(r["id"]) for r in missing]
log.info("%d %ss skipped below ID %d: %s%s", len(missing), self.entity.name, last_done,
missing_ids[:20], " ..." if len(missing) > 20 else "")
log.info("%d %ss not reached yet (run `detail` for those)", not_reached, self.entity.name)
if missing and not dry_run:
self._fetch_all(missing)
return missing_ids
def _list_rows(self) -> List[Dict[str, Any]]:
list_path = self.entity.list_csv()
if not list_path.exists():
raise FileNotFoundError(f"{list_path} not found, run the list stage first")
with list_path.open(newline="", encoding="utf-8") as f:
return list(csv.DictReader(f))
def _done_ids(self) -> Set[int]:
out_path = self.entity.detail_jsonl()
if not out_path.exists():
return set()
with out_path.open(encoding="utf-8") as f:
return {json.loads(line)["id"] for line in f if line.strip()}
def _fetch_all(self, todo: List[Dict[str, Any]]) -> Path:
out_path = self.entity.detail_jsonl()
ok = failed = 0
with out_path.open("a", encoding="utf-8") as out, \
ThreadPoolExecutor(self.fetcher.settings.concurrency) as pool:
futures = {pool.submit(self._fetch_detail, row): row for row in todo}
try:
for n, future in enumerate(as_completed(futures), 1):
row = futures[future]
try:
record = future.result()
except Exception as e:
failed += 1
log.error("%s %s failed: %s", self.entity.name, row["id"], e)
continue
out.write(json.dumps(record, ensure_ascii=False) + "\n")
out.flush()
ok += 1
if n % 50 == 0:
log.info("%d/%d done", n, len(todo))
except KeyboardInterrupt:
log.warning("Interrupted, finishing in-flight requests. Rerun to resume.")
pool.shutdown(cancel_futures=True)
raise
log.info("Wrote %d records to %s, %d failed (rerun to retry)", ok, out_path, failed)
return out_path
def _fetch_detail(self, row: Dict[str, Any]) -> Dict[str, Any]:
try:
html = self.fetcher.get(row["url"])
except NotFound:
# Recorded so reruns don't keep asking for it.
return {"id": int(row["id"]), "url": row["url"], "not_found": True}
return self.parse_page(html, row)

View File

@@ -0,0 +1,15 @@
from config import ENTITIES
from parsers import object as object_parser
from spiders.base import Spider
class ObjectSpider(Spider):
entity = ENTITIES["object"]
listview_id = object_parser.LISTVIEW_ID
csv_fields = object_parser.CSV_FIELDS
def list_row(self, raw, categories):
return object_parser.list_row(raw, categories)
def parse_page(self, html, row):
return object_parser.parse_page(html, row)

View File

@@ -0,0 +1,15 @@
from config import ENTITIES
from parsers import quest
from spiders.base import Spider
class QuestSpider(Spider):
entity = ENTITIES["quest"]
listview_id = quest.LISTVIEW_ID
csv_fields = quest.CSV_FIELDS
def list_row(self, raw, categories):
return quest.list_row(raw, categories)
def parse_page(self, html, row):
return quest.parse_page(html, row)

View File

@@ -0,0 +1,538 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="initial-scale=1">
<script>var _sf_startpt=(new Date()).getTime();</script>
<title>Burned-Out Remains - Object - Forever</title>
<link rel="preconnect" href="https://ex.co"><link rel="preconnect" href="https://bid.g.doubleclick.net"><link rel="preconnect" href="https://vast.doubleverify.com"><link rel="preconnect" href="https://ib.adnxs.com"><link rel="preconnect" href="https://fastlane.rubiconproject.com"><link rel="preconnect" href="https://hbopenbid.pubmatic.com"><link rel="preconnect" href="https://tlx.3lift.com"><link rel="preload" as="script" href="https://tagan.adlightning.com/zam/op.js" fetchpriority="high"><link rel="preload" as="script" href="https://securepubads.g.doubleclick.net/tag/js/gpt.js"><link rel="preload" as="script" href="https://cdn.dotaoutpost.com/stable/js/wowhead.js?type=ab"><link rel="preload" as="script" href="https://zaf.api.fanbyte.com/stable/js/wowhead.js"><link rel="preconnect" href="https://www.googletagmanager.com"><meta name="description" content="Burned-Out Remains is a World of Warcraft object that can be found in Westfall and The Barrens. In the Quest Objects category. Added in Classic World of Warcraft.">
<meta name="google-site-verification" content="SScjPPnjqJ0lGTlZeVs9x0D3_jmctKZ-6nE4cvfGL00">
<meta property="twitter:title" content="Burned-Out Remains">
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:image" content="https://wow.zamimg.com/uploads/screenshots/normal/1304352-burned-out-remains-this-is-the-turn-in-location-for-this-quest-in-the-barrons.jpg">
<meta property="twitter:imageUrl" content="https://wow.zamimg.com/uploads/screenshots/normal/1304352-burned-out-remains-this-is-the-turn-in-location-for-this-quest-in-the-barrons.jpg">
<meta property="twitter:site:id" content="17258481">
<meta property="twitter:site" content="@Wowhead">
<meta property="twitter:domain" content="wowhead.com">
<meta property="twitter:description" content="Burned-Out Remains is a World of Warcraft object that can be found in Westfall and The Barrens. In the Quest Objects category. Added in Classic World of Warcraft.">
<meta property="twitter:image:alt" content="Burned-Out Remains is a World of Warcraft object that can be found in Westfall and The Barrens. In the Quest Objects category. Added in Classic World of Warcraft.">
<meta property="og:title" content="Burned-Out Remains">
<meta property="og:type" content="article">
<meta property="og:image" content="https://wow.zamimg.com/uploads/screenshots/normal/1304352-burned-out-remains-this-is-the-turn-in-location-for-this-quest-in-the-barrons.jpg">
<meta property="og:site_name" content="Wowhead">
<meta property="og:url" content="https://www.wowhead.com/forever/object=415106/burned-out-remains">
<meta property="og:description" content="Burned-Out Remains is a World of Warcraft object that can be found in Westfall and The Barrens. In the Quest Objects category. Added in Classic World of Warcraft.">
<meta property="fb:pages" content="169121139638">
<link rel="canonical" href="https://www.wowhead.com/forever/object=415106/burned-out-remains">
<link rel="alternate" hreflang="en-US" href="https://www.wowhead.com/forever/object=415106/burned-out-remains">
<link rel="alternate" hreflang="x-default" href="https://www.wowhead.com/forever/object=415106/burned-out-remains">
<link rel="alternate" hreflang="fr-FR" href="https://www.wowhead.com/forever/fr/object=415106/restes-brûlés">
<link rel="alternate" hreflang="de-DE" href="https://www.wowhead.com/forever/de/object=415106/ausgebrannte-überreste">
<link rel="alternate" hreflang="es-ES" href="https://www.wowhead.com/forever/es/object=415106/restos-carbonizados">
<link rel="alternate" hreflang="ru-RU" href="https://www.wowhead.com/forever/ru/object=415106/прогоревшие-останки">
<link rel="alternate" hreflang="pt-BR" href="https://www.wowhead.com/forever/pt/object=415106/restos-queimados">
<link rel="alternate" hreflang="zh-CN" href="https://www.wowhead.com/forever/cn/object=415106/焦焚残骸">
<link rel="alternate" hreflang="ko-KR" href="https://www.wowhead.com/forever/ko/object=415106/불타버린-잔해">
<link rel="alternate" hreflang="zh-TW" href="https://www.wowhead.com/forever/tw/object=415106/燒焦的遺骸">
<link rel="alternate" hreflang="es-MX" href="https://www.wowhead.com/forever/mx/object=415106/restos-quemados">
<link rel="search" type="application/opensearchdescription+xml" href="https://www.wowhead.com/forever/opensearch/description?v=3"
title="Wowhead (Forever)">
<link rel="icon" type="image/png" href="https://wow.zamimg.com/images/logos/favicon-classicplus.png">
<link rel="apple-touch-icon-precomposed" href="https://wow.zamimg.com/apple-touch-icon.png">
<script type="application/ld+json">{"@context":"http:\/\/schema.org","@type":"Intangible","mainEntityOfPage":{"@type":"WebPage","@id":"https:\/\/www.wowhead.com\/forever\/object=415106\/burned-out-remains"},"name":"Burned-Out Remains","url":"https:\/\/www.wowhead.com\/forever\/object=415106\/burned-out-remains","logo":"https:\/\/wow.zamimg.com\/images\/logos\/wh-logo.png","publisher":{"@type":"Organization","name":"Wowhead Logo","logo":{"@type":"ImageObject","url":"https:\/\/wow.zamimg.com\/images\/logos\/amp-valid.png","width":395,"height":60}},"keywords":"Database, Forever Database, Objects","description":"Burned-Out Remains is a World of Warcraft object that can be found in Westfall and The Barrens. In the Quest Objects category. Added in Classic World of Warcraft.","image":{"@type":"ImageObject","url":"https:\/\/wow.zamimg.com\/uploads\/screenshots\/normal\/1304352-burned-out-remains-this-is-the-turn-in-location-for-this-quest-in-the-barrons.jpg","height":903,"width":1600}}</script>
<script>
let cdnErrorLeaving = false;
function cdnErrorOnLeave() {
cdnErrorLeaving = true;
// A page that is still here a moment later had its unload cancelled (an unsaved-changes prompt). If the next
// page takes longer than this to start arriving, a cancel after the reset still redirects; that is rare enough
// to accept.
setTimeout(function () {
cdnErrorLeaving = false;
}, 1000);
}
addEventListener('beforeunload', cdnErrorOnLeave);
// Firefox won't keep a page with a beforeunload listener in its back/forward cache, so drop the listener once every
// stylesheet and script has loaded or failed, when there is nothing left to cancel.
addEventListener('load', function () {
removeEventListener('beforeunload', cdnErrorOnLeave);
});
// Scripts get no retry (running one twice, or out of order, is worse than the error page), so a stop during a
// script load still redirects.
function cdnErrorHandler(type, el) {
if (cdnErrorLeaving) {
return;
}
if (el && el.tagName === 'LINK' && !el.hasAttribute('data-cdn-retry')) {
const retry = el.cloneNode();
retry.setAttribute('data-cdn-retry', '');
retry.addEventListener('load', function () {
el.remove();
});
el.after(retry);
return;
}
let url = "\/error-static-cdn?hash=303eb9adfb1&at=1793053487&redirect=1" + '&type=' + encodeURIComponent(type);
if (location.hash.length > 1) {
url += '&fragment=' + encodeURIComponent(location.hash.substr(1));
}
location.href = url;
}
</script><link rel="stylesheet" type="text/css" href="https://wow.zamimg.com/css/standard/universal.css?303eb9adfb1" onerror="cdnErrorHandler(&quot;css&quot;, this);">
<link rel="stylesheet" type="text/css" href="https://wow.zamimg.com/css/standard/Components/universal/ZamUiPrimitives.css?303eb9adfb1">
<link rel="stylesheet" type="text/css" href="https://wow.zamimg.com/css/standard/global.css?303eb9adfb1">
<link rel="stylesheet" type="text/css" href="https://wow.zamimg.com/css/standard/Components/universal/ZamPrimitivesOverrides.css?303eb9adfb1">
<link rel="stylesheet" type="text/css" href="https://wow.zamimg.com/css/standard/tools/book.css?303eb9adfb1">
<script src="https://wow.zamimg.com/js/preamble.js?066f8967" onerror="cdnErrorHandler(&quot;js&quot;);"></script>
<script>
g_user = {"id":0,"name":"","roles":0,"ads":true,"canDeleteComments":false};
</script>
<script src="https://cdn.cookielaw.org/scripttemplates/otSDKStub.js" type="text/javascript" charset="UTF-8" data-domain-script="11986010-b318-46fd-ab82-e05142e195e2" data-ignore-ga="true"></script><script>function OptanonWrapper() {}</script>
<script>window.WH_WASBootstrap=new function(){this.initEarly=function(){let a=null;try{const e=document.getElementById("data.WAS.init");a=e?JSON.parse(e.textContent):null}catch(e){a=null}if(!a||!window.g_user||!g_user.ads){return}window.WH_WAS=window.WH_WAS||{};if(window.WH_WAS.initializedEarly){return}window.WH_WAS.initData=a;window.WH_WAS.deferredSlotRenderCallbacks=window.WH_WAS.deferredSlotRenderCallbacks||[];window.WH_WAS.deferredSkinRenderCallbacks=window.WH_WAS.deferredSkinRenderCallbacks||[];window.WH_WAS.deferredRejectedSlots=window.WH_WAS.deferredRejectedSlots||[];window.googletag=window.googletag||{};googletag.cmd=googletag.cmd||[];window.zamaf=window.zamaf||{};zamaf.cmd=zamaf.cmd||[];zamaf.setTargeting=function(){const e=a.targetData||{};googletag.pubads().setTargeting("Channel",e.channelName);googletag.pubads().setTargeting("ChannelGroup",e.channelGroup);googletag.pubads().setTargeting("DataEnvironment",e.dataEnv);googletag.pubads().setTargeting("DataTree",e.dataTree);if(e.entityType){googletag.pubads().setTargeting("EntityType",e.entityType)}googletag.pubads().setTargeting("Game",e.game);googletag.pubads().setTargeting("Universe",e.universe)};zamaf.slotRenderCallback=function(){if(window.WH&&WH.WAS&&typeof WH.WAS.slotRenderCallback==="function"){return WH.WAS.slotRenderCallback.apply(WH.WAS,arguments)}window.WH_WAS.deferredSlotRenderCallbacks.push(Array.from(arguments))};zamaf.skinRenderCallback=function(){if(window.WH&&WH.WAS&&typeof WH.WAS.displaySkin==="function"){return WH.WAS.displaySkin.apply(WH.WAS,arguments)}window.WH_WAS.deferredSkinRenderCallbacks.push(Array.from(arguments));return true};window.WH_WAS.rejectedHandler=function(){window.WH_WAS.deferredRejectedSlots.push(Array.from(arguments))};zamaf.cmd.push({event:"zaf:slot:rejected",handler:function(){return window.WH_WAS.rejectedHandler.apply(null,arguments)}});window.WH_WAS.zafRejectedHandlerRegistered=true;const n=new URLSearchParams(location.search);const d=n.get("zafAbId");const i=n.get("zafId");["https://tagan.adlightning.com/zam/op.js","https://securepubads.g.doubleclick.net/tag/js/gpt.js"].forEach(e);t((function(){["https://cdn.dotaoutpost.com/stable/js/wowhead.js?type=ab"+(d?"&id="+d:""),"https://zaf.api.fanbyte.com/stable/js/wowhead.js"+(i?"?id="+i:"")].forEach(e)}));window.WH_WAS.scriptsLoaded=true;if(zamaf.initialize){zamaf.initialize();window.WH_WAS.zafInitialized=true}window.WH_WAS.initializedEarly=true};function e(e){const t=document.createElement("script");t.async=true;t.src=e;if(e==="https://tagan.adlightning.com/zam/op.js"){t.setAttribute("fetchpriority","high")}document.head.appendChild(t)}function t(e){if(document.body){e();return}document.addEventListener("bodyAvailable",e,{once:true})}};</script>
<script type="application/json" id="data.pageMeta">{"activeDbChanged":{"1":1791976965,"2":1792711558,"4":1761625051,"5":1793000437,"6":1733348219,"7":1734645791,"8":1711418938,"9":1785165687,"10":1790720440,"11":1785142632,"14":1785135856,"15":1786100712,"16":1792500090},"anonymousSubdomain":"nether","dataEnv":{"active":{"ptr":true,"ptr2":true,"beta":false,"d4ptr":false,"d4beta":false,"classicptr":true},"env":16,"tree":16,"versions":{"1":"12.1.5","2":"12.1.7","4":"1.15.8","5":"2.5.6","6":"2.6","7":"1.8.2","8":"3.4.3","9":"3.0.2","10":"12.1.5","11":"4.4.2","14":"1.15.8","15":"5.5.4","16":"1.60.1"},"phases":{"4":8,"8":null,"11":null,"14":7,"15":null,"16":null},"maxLevel":60},"dev":false,"envDomain":"wowhead.com","hasSidebar":true,"logo":"classicplus","modelViewerVersion":{"wow":{"wrath":"35ee72d","live":"35ee72d","ptr":"dd13b80","ptr2":"dd13b80","classic":"35ee72d","tbc":"35ee72d","mists":"35ee72d","staging":"e84c745","cata":"35ee72d","classicplus":"35ee72d"},"playerHousing":{"staging":"23759a2","live":"070f74e","ptr":"be2a862","ptr2":"be2a862"}},"page":"object","scriptVersionQueryString":"303eb9adfb1","serverTime":"2026-10-26T17:24:47-05:00","staticResizing":{"limit":3000,"throttle":{"start":3000,"percent":0}},"staticUrl":"https:\/\/wow.zamimg.com","theme":"standard","wowhead":true,"tracking":{"groups":{"premium":true,"house_ad":false,"ads":false,"notifications":false,"announcements":false,"model_viewer":false,"content_viewers":false,"navigation":false,"newsletter":false,"filters":false,"content":true,"performance":false,"errors":false,"diagnostics":false}},"availableDataEnvs":[1,2,4,5,6,7,8,9,10,11,14,15,16],"restrictedDataEnvs":[],"restrictedEntities":[],"disabledInTrees":[1,5,6,7,8,9,11,15]}</script>
<script type="application/json" id="data.notifications.public-key">"BCv9I9cvqAYEi2ddxSOqAbkm8KTL-CNlIGmXk8R7ievkXR7QfBrNtkXQqk73vleylt5MlZQ8jFQ-tEQ_ObVxxMI"</script>
<script type="application/json" id="data.newsletter.signup">{"newsletters":[{"value":"wow-retail","label":"Wowhead Retail News and Updates","checked":true,"subscribed":false,"popupEnvs":[1]},{"value":"wow-mists-classic","label":"Wowhead Mists of Pandaria Classic News and Updates","checked":false,"subscribed":false,"popupEnvs":[4,15]},{"value":"wow-forever","label":"Wowhead Forever News and Updates","checked":false,"subscribed":false,"popupEnvs":[16]}],"recaptchaSiteKey":"6LfPERwTAAAAAJJlVvIwSzYk_upePdoG-01tw0VX","tosUrl":"https:\/\/corp.fanbyte.com\/legal\/terms","privacyUrl":"https:\/\/corp.fanbyte.com\/legal\/privacy"}</script>
<script type="application/json" id="data.page.info">{"commentsLocked":false,"entityId":415106,"entityType":2}</script>
<script type="application/json" id="data.page.urlArticles">[]</script>
<script type="application/json" id="data.WAS.init">{"targetData":{"channelGroup":"Database Detail","channelName":"Object","dataEnv":"Forever","dataTree":"Forever","entityType":"Object","game":"WoW","universe":"Warcraft"},"ads":["skin"],"email":null}</script><script>window.WH_WASBootstrap && window.WH_WASBootstrap.initEarly();</script>
<script src="https://wow.zamimg.com/js/3rd-party/jquery.2.1.3.min.js?fa3be9bf"></script>
<script src="https://wow.zamimg.com/js/tooltips.js?fdeadb5a"></script>
<script src="https://wow.zamimg.com/global-strings/classicplus.enus.js?hash=PFLe03o84aagOxgJ9W-07w"></script>
<script src="https://nether.wowhead.com/forever/menus/root?hash=7dbca72ff72b23dc23687fdc1ae3262e" onerror="cdnErrorHandler(&quot;anon-js&quot;);"></script>
<script src="https://nether.wowhead.com/forever/menus?hash=7dbca72ff72b23dc23687fdc1ae3262e" async=""></script>
<script src="https://wow.zamimg.com/js/locale/terms/enus.js?0173b0c1"></script>
<script src="https://wow.zamimg.com/js/generated/game-env.js?e5c1729e"></script>
<script src="https://nether.wowhead.com/forever/data/global?dv=86&amp;db=1792500090&amp;versionsSig=8bbc4bda5d5cb360c3dcb8590eb91625"></script>
<script src="https://wow.zamimg.com/js/global-core.js?70b9b9fd"></script>
<script src="https://wow.zamimg.com/js/global-tags.js?20399cbc"></script>
<script src="https://wow.zamimg.com/js/react/runtime.b187eec5.js"></script>
<script src="https://wow.zamimg.com/js/react/vendors-react.80ccf0e8.js"></script>
<script src="https://wow.zamimg.com/js/react/vendors-fontawesome.7e553640.js"></script>
<script src="https://wow.zamimg.com/js/react/main.d93c693f.js"></script>
<script src="https://wow.zamimg.com/js/WH/Listview/Templates/comment.js?5a42e53b"></script>
<script src="https://wow.zamimg.com/js/Book.js?070ad7e1"></script>
<script>WH.WAS.init();</script>
<script>
WH.Track.gaInit();
</script>
<script>
(function(h,o,u,n,d) {
h=h[d]=h[d]||{q:[],onReady:function(c){h.q.push(c)}}
d=o.createElement(u);d.async=1;d.src=n
n=o.getElementsByTagName(u)[0];n.parentNode.insertBefore(d,n)
})(window,document,'script','https://www.datadoghq-browser-agent.com/us5/v6/datadog-rum.js','DD_RUM')
window.DD_RUM.onReady(function() {
window.DD_RUM.init({"clientToken":"pub983d2b36ec5246e30991ebd4b6dccaaa","applicationId":"f2771e64-a11a-4ddd-a6ab-08e7e43d6e6b","site":"us5.datadoghq.com","service":"wh-site","env":"prod","sessionSampleRate":0.03,"sessionReplaySampleRate":0,"trackUserInteractions":true,"trackResources":true,"trackLongTasks":true,"defaultPrivacyLevel":"mask-user-input","enableExperimentalFeatures":["feature_flags"],"version":"303eb9adfb1"});
window.DD_RUM?.addFeatureFlagEvaluation?.('ads', true);
});
</script><script>window.awsWafCookieDomainList = ["wowhead.com"];</script>
<script type="text/javascript" src="https://ad65c0e8b968.edge.sdk.awswaf.com/ad65c0e8b968/374c23fd6c5f/challenge.js" defer></script>
<script src="https://nether.wowhead.com/forever/data/item-bonuses?dv=19&db=1792500090"></script>
<script src="https://nether.wowhead.com/forever/data/zones?dv=23&db=1792500090"></script>
<script src="https://nether.wowhead.com/forever/data/difficulties?dv=2&db=1792500090"></script>
</head>
<body class="standard-layout locale-0 has-ads has-rail"><script>
// First statement after <body> on purpose: this stamps body[data-display] and body[data-rails]
// while the document is still only a <body> tag, so the first paint already has them. Anything
// later can be beaten by a paint mid-parse, and those two attributes drive the content column
// and the rail track. (The ZUL bar's mobile mode does NOT depend on this call site -- check()
// answers that from the viewport whether or not this ran.) Idempotent; Responsive.init() also
// calls it. Guarded like initPortalRooms() below: a bundle missing from a deploy must not kill
// the rest of inline page init with an uncaught TypeError.
if (window.WH && WH.Layout && WH.Layout.Responsive) {
WH.Layout.Responsive.initPrepaintState();
}
</script>
<a href="#main" class="wh-button zam-button skip-link">
<span class="wh-button-text body-text zam-button-text" data-text-style="body-small-bold">Skip to Main Content</span>
</a>
<script>
document.addEventListener('bodyAvailable', () => {
const skipLink = document.querySelector('.skip-link');
const updateSkipLink = () => {
const page = document.body.dataset.page;
if (page === 'guide') {
skipLink.setAttribute('href', '#interior-sidebar-content');
} else {
skipLink.setAttribute('href', '#main');
}
};
updateSkipLink();
const observer = new MutationObserver(updateSkipLink);
observer.observe(document.body, {
attributes: true,
attributeFilter: ['data-page']
});
});
</script>
<div id="react-root-main" style="display: none"></div>
<script>
// Guard against a deploy that shipped without the React bundle: if WH.React is
// absent the page loses React features, but the rest of the inline page init
// (bodyAvailable, WH.WAS.init, page state) must still run rather than the whole
// page dying on an uncaught TypeError here.
if (window.WH && WH.React) {
WH.React.initPortalRooms();
} else if (window.WH && WH.error) {
WH.error('WH.React unavailable at initPortalRooms(); React bundle missing from this deploy.');
}
document.dispatchEvent(new CustomEvent('bodyAvailable'));
</script>
<noscript>
<div id="noscript-wrapper">
<div id="noscript-text">
<b>This site makes extensive use of JavaScript.</b><br>
Please enable JavaScript in your browser. </div>
</div>
</noscript>
<script>
WH.Layout.initPageState(0, null);
Menu.processExtraInstructions();
</script>
<div class="header-wrapper" role="banner">
<div class="header">
<div id="data-tree-switcher">
<script>WH.Layout.DataTreeSwitcher.init();</script>
</div>
<div class="header-left">
<a href="/forever" class="header-logo" aria-label="Home"></a>
</div>
<div class="header-right">
<div class="header-search">
<form action="/forever/search"></form>
</div>
<div class="data-env-links">
<div class="data-env-links-group"></div></div>
<script>WH.Layout.DataTreeSwitcher.checkEnvDimensions();</script>
</div>
<script>WH.Layout.User.init();</script>
</div>
</div>
<div id="newsletter-cta-wrapper"></div>
<div class="layout-wrapper">
<div class="layout">
<div class="skin-wrapper" data-position="left"></div>
<div id="page-content" class="page-content">
<div class="main" id="main">
<div class="blocks"><div class="blocks-video zaf-block" id="zaf-video-top" style="height: var(--ad-video-top-height, 250px)"></div><div class="zaf-block" id="zaf-billboard" data-zaf-slot="true"></div></div> <div class="page-controls"></div>
<div class="main-contents" id="main-contents" role="main">
<div id="announcement-0"></div>
<div id="announcement-433"></div>
<script>
new WH.Announcement({"parent":"announcement-433","id":433,"text":"[div class=announcement-content-title style=\"padding-left: 130px\"][b]Help build out the Forever database[\/b][\/div]\r\n[div class=announcement-content-subtitle style=\"padding-left: 130px\"]Item stats stay hidden in Forever until someone actually discovers them, so the database can only be truly complete with your help. [url=https:\/\/www.wowhead.com\/client]Download the Wowhead Client[\/url] and help keep Wowhead's data up-to-date![\/div]","mobiletext":"","name":"Forever Client CTA - database pages","style":"background: url(https:\/\/wow.zamimg.com\/images\/logos\/horizontal\/classicplus-rocket-large.webp) -35px center \/ auto 100px no-repeat, linear-gradient(to right, #1d8dcd 0%, #303030 25%, #303030 100%)","class":"promo","status":1,"mode":1,"desktopOnly":true});
</script>
<script>
WH.Layout.set({"pageName":"object","activeTab":0,"breadcrumb":[]});
// WH.Layout.init() builds header navigation from async menu data.
Menu.onLoad(WH.Layout.init);
skipCaptcha = true;
g_host = "https:\/\/www.wowhead.com";
</script>
<div id="infobox-original-position">
<table class="infobox"><tr>
<td>
<div id="premium-house-block" class="house-cta-container"></div> <script>
WH.React.initComponent(
WH.ge('premium-house-block'),
'ui/components/house-cta/PremiumCTA',
{"titleLines":["Drop the Ads.","Keep the Loot."],"buttonLabel":"Upgrade Now","trackingOverride":{"placement":"db_infobox_top","pageType":"database"}},
);
</script>
</td>
<td>
<table class="infobox-inner-table">
<tr class="infobox-heading">
<th>Quick Facts</th> </tr>
<tr>
<td>
<div class="infobox-spacer"></div>
<div id="infobox-contents-0"></div>
<script>
WH.markup.printHtml("[ul][li]Added in patch [acronym=\"1.15.1.53247\"]1.15.1[\/acronym] \"Season of Discovery\"[\/li][\/ul]", "infobox-contents-0", {
allow: WH.markup.CLASS.STAFF,
dbPage: true, });
</script>
</td></tr>
</table>
</td>
<td>
<table class="infobox-inner-table">
<tr class="infobox-heading">
<th id="infobox-screenshots">Screenshots</th> </tr>
<tr>
<td>
<div class="infobox-spacer"></div>
<div id="infobox-sticky-ss"></div> </td></tr>
</table>
</td>
<td>
<table class="infobox-inner-table">
<tr class="infobox-heading">
<th id="infobox-videos">Videos</th> </tr>
<tr>
<td>
<div class="infobox-spacer"></div>
<div id="infobox-sticky-vi"></div> </td></tr>
</table>
</td>
</tr></table>
</div>
<script>WH.prepInfobox();</script>
<script>
WH.onLoad(function () {
ss_appendSticky();
vi_appendSticky();
});
</script><script type="text/javascript">//<![CDATA[
var _ = g_users;
_["Onsokomaru"]={"border":0,"roles":0,"joined":"2020-11-24T09:23:04-06:00","posts":1,"gold":3,"silver":18,"reputation":10979,"copper":76,"tierClass":null,"tierTitle":null,"avatar":1,"avatarmore":"5332199"};
WH.Gatherer.addData(2, 16, {"415106":{"name_enus":"Burned-Out Remains","displayName":""},"424010":{"name_enus":"Nailed Plank","displayName":""},"424005":{"name_enus":"Pocket Litter","displayName":""},"424012":{"name_enus":"Mound of Dirt","displayName":""},"424007":{"name_enus":"Carved Figurine","displayName":""},"406918":{"name_enus":"Messenger Bag","displayName":""},"424006":{"name_enus":"Hastily Rolled-Up Satchel","displayName":""}});
WH.Gatherer.addData(3, 16, {"211527":{"name_enus":"Cozy Sleeping Bag","quality":3,"icon":"inv_misc_bag_bigbagofenchantments","screenshot":{"id":1306460,"imageType":2},"jsonequip":{"buyprice":171717,"cooldown":3600000,"itemSquishEraId":0,"maxcount":1,"reqlevel":1,"sellprice":1717},"attainable":0,"flags2":28672,"flags5":0,"displayName":"","qualityTier":0,"qualityTierTexture":""},"216619":{"name_enus":"Student Fodder","quality":2,"icon":"inv_misc_food_pinenut","screenshot":{},"jsonequip":{"cooldown":300000,"dmgrange":1,"health":500,"healthrgn":175,"itemSquishEraId":0},"attainable":0,"flags2":24580,"flags5":0,"displayName":"","qualityTier":0,"qualityTierTexture":""}});
WH.Gatherer.addData(7, 16, {"17":{"name_enus":"The Barrens"},"40":{"name_enus":"Westfall"},"406":{"name_enus":"Stonetalon Mountains"},"38":{"name_enus":"Loch Modan"},"267":{"name_enus":"Hillsbrad Foothills"},"45":{"name_enus":"Arathi Highlands"}});
var lv_comments0 = [{"commentv2":1,"number":0,"id":6433636,"nreplies":0,"sticky":0,"user":"Onsokomaru","body":"[b][u]Horde: follow below; Alliance: swap the order of first 2 steps[\/u][\/b]\n\nHow to obtain [item=211527] & 5 [item=216619]:\n\n1. 1. Travel to 46, 73 in [zone=17]\nHere, interact with [object=415106] and accept the quest.\n\n2. Travel to 37, 50 in [zone=40]\nInteract with [object=424010]\n\n3. Travel to 51, 51 in [zone=406]\nFrom the road, follow the opening into the mountains, and follow the path until you reach the camp\nAt the camp, interact with [object=424005]\nRun through the camp and jump down the mountains to reach the ledge with [object=424012] that you have to interact with\n\n4. Travel to the Dam at 49, 12 in [zone=38]\nJump down to the ledge and interact with [object=424007]\n\n5. Travel to 81, 56 in [zone=267]\nWhile facing the gate toward [zone=45], turn left and follow the wall until you reach the fallen cart\nFollow the little jumping puzzle toward the camp\nInteract with [object=406918] then interact with [object=424006]\n\n[b][u]Orc Hunter Journey to unlock it:[\/u][\/b] https:\/\/www.youtube.com\/watch?v=W_Yl_7lE_9Q\n\n[b][u]It was originally recorded for SoD, but the same quest carried over.[\/u][\/b]","date":"2026-09-22T16:31:56-05:00","rating":2,"indent":0,"roles":0,"deleted":0,"outofdate":0,"userRating":0,"replies":[],"dataTree":16,"markupDisabledEnvironments":{"ptr":false,"ptr2":false,"beta":false,"d4ptr":false,"d4beta":false,"classicptr":false},"lastEdit":["2026-09-22T16:39:19-05:00",1,"Onsokomaru"]}];
</script>
<noscript><div id="user-comments"><h2 class="heading-size-2">Comments</h2><div class="user-post-"><h3 class="heading-size-3">Comment by <b>Onsokomaru</b></h3><b><u>Horde: follow below; Alliance: swap the order of first 2 steps</u></b><br /><br />How to obtain <a href="/forever/item=211527/cozy-sleeping-bag">Cozy Sleeping Bag</a> &amp; 5 <a href="/forever/item=216619/student-fodder">Student Fodder</a>:<br /><br />1. 1. Travel to 46, 73 in <a href="/forever/zone=17/the-barrens">The Barrens</a><br />Here, interact with <a href="/forever/object=415106/burned-out-remains">Burned-Out Remains</a> and accept the quest.<br /><br />2. Travel to 37, 50 in <a href="/forever/zone=40/westfall">Westfall</a><br />Interact with <a href="/forever/object=424010/nailed-plank">Nailed Plank</a><br /><br />3. Travel to 51, 51 in <a href="/forever/zone=406/stonetalon-mountains">Stonetalon Mountains</a><br />From the road, follow the opening into the mountains, and follow the path until you reach the camp<br />At the camp, interact with <a href="/forever/object=424005/pocket-litter">Pocket Litter</a><br />Run through the camp and jump down the mountains to reach the ledge with <a href="/forever/object=424012/mound-of-dirt">Mound of Dirt</a> that you have to interact with<br /><br />4. Travel to the Dam at 49, 12 in <a href="/forever/zone=38/loch-modan">Loch Modan</a><br />Jump down to the ledge and interact with <a href="/forever/object=424007/carved-figurine">Carved Figurine</a><br /><br />5. Travel to 81, 56 in <a href="/forever/zone=267/hillsbrad-foothills">Hillsbrad Foothills</a><br />While facing the gate toward <a href="/forever/zone=45/arathi-highlands">Arathi Highlands</a>, turn left and follow the wall until you reach the fallen cart<br />Follow the little jumping puzzle toward the camp<br />Interact with <a href="/forever/object=406918/messenger-bag">Messenger Bag</a> then interact with <a href="/forever/object=424006/hastily-rolled-up-satchel">Hastily Rolled-Up Satchel</a><br /><br /><b><u>Orc Hunter Journey to unlock it:</u></b> <a href="https://www.youtube.com/watch?v=W_Yl_7lE_9Q" rel="nofollow ugc noopener">https://www.youtube.com/watch?v=W_Yl_7lE_9Q</a><br /><br /><b><u>It was originally recorded for SoD, but the same quest carried over.</u></b></div>
</div>
</noscript>
<script type="text/javascript">
var lv_screenshots = [{"id":1304352,"user":"YaketheSnake","date":"2026-09-30T00:06:12-05:00","width":1600,"height":903,"type":2,"typeId":415106,"imageType":2,"staffOnly":false,"caption":"This is the turn in location for this quest in the barrons.","sticky":1}];
var lv_videos = [{"id":437914,"user":"Onsokomaru","date":"2026-09-22T16:31:56-05:00","videoType":1,"videoId":"W_Yl_7lE_9Q","type":2,"typeId":415106,"url":null,"sticky":1}];
var g_pageInfo = {type: 2, typeId: 415106, name: "Burned-Out Remains"};
WH.Layout.set({breadcrumb: [0, 5, -2]});
//]]></script>
<div class="text">
<div class="db-action-buttons"><script>WH.onLoad(function () {
var linksButton = WH.ge('open-links-button');
if (linksButton) {
linksButton.dataset.displayId = 2610;
linksButton.dataset.modelEntityType = 2;
linksButton.dataset.modelEntityId = 415106;
}
});</script><a href="javascript:" id="wh-mv-view-in-3d-button" class="btn btn-site btn-small" style="margin-left:5px;float:right" onclick="WH.Wow.ModelViewer.showLightbox({&quot;type&quot;:2,&quot;typeId&quot;:415106,&quot;displayId&quot;:2610})" data-mv-type="2" data-mv-type-id="415106" data-mv-display-id="2610">View in 3D</a><a href="javascript:" id="open-links-button" class="btn btn-site btn-small fa fa-fw fa-link" style="margin-left:5px;float:right" onclick="WH.Links.show(this, {&quot;type&quot;:2,&quot;typeId&quot;:415106}, event);">Links</a></div><h1 class="heading-size-1">Burned-Out Remains</h1><div>This object can be found in <span id="locations"> <a href="javascript:" onclick="
myMapper.update({
zone: 40,
level: 0,
});
WH.setSelectedLink(this, 'mapper');
return false;
" onmousedown="return false">Westfall</a>
&nbsp;(2) and <a href="javascript:" onclick="
myMapper.update({
zone: 17,
level: 0,
});
WH.setSelectedLink(this, 'mapper');
return false;
" onmousedown="return false">The Barrens</a>
.</span>
</div><div id="k6b43j6b"></div>
<div style="clear:left"></div>
<script>var g_mapperData = {"17":[{"count":1,"coords":[[46.4,73.9]],"uiMapId":1413,"uiMapName":"The Barrens"}],"40":[{"count":2,"coords":[[37.4,50.6],[37.5,50.7]],"uiMapId":1436,"uiMapName":"Westfall"}]};
var myMapper = new Mapper({"parent":"k6b43j6b","name":"Burned-Out Remains"});
WH.gE(WH.ge('locations'), 'a')[0].onclick();
</script><div id="infobox-alternate-position"></div><script>WH.Responsive.placeInfobox();</script>
<div class="db-page-collection-tracker-cta"></div>
<script>
const cta = WH.Tools.createWowheadClientCta();
if (cta) {
$('.db-page-collection-tracker-cta').append(cta);
}
</script>
<h2 class="heading-size-2 clear">Related</h2></div>
<div id="jkbfksdbl4"></div>
<div id="lkljbjkb574" class="listview">
</div>
<script type="text/javascript">//<![CDATA[
WH.Gatherer.addData(2, 16, {"415106":{"name_enus":"Burned-Out Remains","displayName":""}});
$.extend(g_objects[415106], {"displayName":"Burned-Out Remains","id":415106,"location":[40,17],"name":"Burned-Out Remains","type":-2,"envChange":{"status":"unchanged","labels":[],"lines":[]}});
WH.Gatherer.addData(3, 16, {"2459":{"name_enus":"Swiftness Potion","quality":1,"icon":"inv_potion_95","screenshot":{},"jsonequip":{"avgbuyout":4800,"cooldown":120000,"itemSquishEraId":0,"reqlevel":5,"sellprice":25},"attainable":0,"flags2":24580,"flags5":0,"displayName":"","qualityTier":0,"qualityTierTexture":""},"3388":{"name_enus":"Lesser Troll's Blood Elixir","quality":1,"icon":"inv_potion_79","screenshot":{},"jsonequip":{"avgbuyout":1476,"cooldown":3000,"healthrgn":6,"itemSquishEraId":0,"reqlevel":15,"sellprice":40},"attainable":0,"flags2":24580,"flags5":0,"displayName":"","qualityTier":0,"qualityTierTexture":""},"1652":{"name_enus":"Sturdy Lunchbox","quality":1,"icon":"inv_box_01","screenshot":{},"jsonequip":{"avgbuyout":24549,"itemSquishEraId":0,"sellprice":5000,"slotbak":18},"attainable":0,"flags2":24580,"flags5":0,"displayName":"","qualityTier":0,"qualityTierTexture":""},"4470":{"name_enus":"Simple Wood","quality":1,"icon":"inv_tradeskillitem_01","screenshot":{},"jsonequip":{"avgbuyout":573,"buyprice":38,"itemSquishEraId":0,"sellprice":9},"attainable":0,"flags2":-2147459068,"flags5":0,"displayName":"","qualityTier":0,"qualityTierTexture":""},"4471":{"name_enus":"Flint and Tinder","quality":1,"icon":"inv_stone_11","screenshot":{},"jsonequip":{"avgbuyout":1198,"buyprice":135,"itemSquishEraId":0,"sellprice":33},"attainable":0,"flags2":24580,"flags5":0,"displayName":"","qualityTier":0,"qualityTierTexture":""},"221498":{"name_enus":"Old Toolbox","quality":1,"icon":"inv_box_02","screenshot":{},"jsonequip":{"dmgrange":1,"itemSquishEraId":0,"sellprice":5000,"slotbak":18},"attainable":0,"flags2":24580,"flags5":0,"displayName":"","qualityTier":0,"qualityTierTexture":""},"2901":{"name_enus":"Mining Pick","quality":1,"icon":"inv_pick_02","screenshot":{},"jsonequip":{"appearances":{"0":[1379,""]},"avgbuyout":393,"buyprice":10,"displayid":1379,"dmgmax1":4,"dmgmin1":2,"dmgrange":0.6,"dmgtype1":0,"dps":1.5,"itemSquishEraId":0,"mledmgmax":4,"mledmgmin":2,"mledps":1.5,"mlespeed":2,"reqlevel":1,"sellprice":2,"slotbak":21,"speed":2},"attainable":0,"flags2":24580,"flags5":0,"displayName":"","qualityTier":0,"qualityTierTexture":""},"3334":{"name_enus":"Farmer's Shovel","quality":2,"icon":"inv_misc_shovel_01","screenshot":{},"jsonequip":{"appearances":{"0":[7495,""]},"avgbuyout":5000,"displayid":7495,"dmgmax1":32,"dmgmin1":21,"dmgrange":0.4,"dmgtype1":0,"dps":7.16,"dura":35,"itemSquishEraId":0,"mledmgmax":32,"mledmgmin":21,"mledps":7.16,"mlespeed":3.7,"reqlevel":5,"sellprice":68,"sheathtype":1,"slotbak":17,"speed":3.7,"str":1},"attainable":0,"flags2":16388,"flags5":0,"displayName":"","qualityTier":0,"qualityTierTexture":""}});
var tabsRelated = new Tabs({parent: WH.ge('jkbfksdbl4'), trackable: "WoW Object"});
new Listview({template: 'quest', id: 'starts', name: WH.TERMS.starts, tabs: tabsRelated, parent: 'lkljbjkb574', data: [{"category":17,"category2":1,"id":79007,"itemrewards":[[2459,1],[3388,1]],"level":22,"name":"... and that note you found","reqlevel":14,"side":2,"xp":1750,"envChange":{"status":"updated","labels":["level_changed","rewards_changed","renamed","reworded"],"lines":["Renamed from ...and that note you found","Level 22 instead of 40","Description reworded","Experience 1750 instead of 3150","Rewards: adds Lesser Troll's Blood Elixir","Rewards: no longer Strong Troll's Blood Potion"],"notShown":["Description reworded","Experience 1750 instead of 3150","Rewards: adds Lesser Troll's Blood Elixir","Rewards: no longer Strong Troll's Blood Potion"]}},{"category":17,"category2":1,"id":79192,"itemchoices":[[2901,1],[3334,1]],"itemrewards":[[1652,1],[4470,1],[4471,1],[221498,1]],"level":24,"name":"Stepping Stones","reqlevel":14,"side":3,"xp":1950,"envChange":{"status":"updated","labels":["level_changed","rewards_changed"],"lines":["Level 24 instead of 40","Experience 1950 instead of 3150","Rewards: adds Old Toolbox"],"notShown":["Experience 1950 instead of 3150","Rewards: adds Old Toolbox"]}}]});
new Listview({template: 'quest', id: 'ends', name: WH.TERMS.ends, tabs: tabsRelated, parent: 'lkljbjkb574', data: [{"category":40,"category2":0,"id":79008,"itemrewards":[[2459,1],[3388,1]],"level":22,"name":"... and that note you found","reqlevel":14,"side":1,"xp":1750,"envChange":{"status":"updated","labels":["level_changed","rewards_changed","renamed","reworded"],"lines":["Renamed from ...and that note you found","Level 22 instead of 40","Description reworded","Experience 1750 instead of 3150","Rewards: adds Lesser Troll's Blood Elixir","Rewards: no longer Strong Troll's Blood Potion"],"notShown":["Description reworded","Experience 1750 instead of 3150","Rewards: adds Lesser Troll's Blood Elixir","Rewards: no longer Strong Troll's Blood Potion"]}}]});
new Listview({template: 'object', id: 'same-model-as', name: WH.TERMS.samemodelas_stc, tabs: tabsRelated, parent: 'lkljbjkb574', data: [{"displayName":"Dark Iron Dwarf Corpse","id":175608,"name":"Dark Iron Dwarf Corpse","type":-2,"envChange":{"status":"unchanged","labels":[],"lines":[]}},{"displayName":"Burned-Out Remains","id":415107,"location":[40],"name":"Burned-Out Remains","type":-2,"envChange":{"status":"unchanged","labels":[],"lines":[]}}]});
</script><script>new Listview({
data: lv_comments0,
id: 'comments',
name: WH.TERMS.comments,
parent: lkljbjkb574,
sourceScopes: true,
tabOpts: {className: 'ugc-tab ugc-tab-comments'},
tabs: tabsRelated,
template: 'comment',
});new Listview({
data: lv_screenshots,
id: 'screenshots',
name: WH.TERMS.screenshots,
parent: 'lkljbjkb574',
tabOpts: {className: 'ugc-tab ugc-tab-screenshots'},
tabs: tabsRelated,
template: 'screenshot',
});if (lv_videos.length) {
new Listview({
data: lv_videos,
id: 'videos',
name: WH.TERMS.videos,
parent: 'lkljbjkb574',
tabs: tabsRelated,
tabOpts: {className: 'ugc-tab ugc-tab-videos'},
template: 'video',
});
}
if (window.lv_videos_english && lv_videos_english.length) {
new Listview({
data: lv_videos_english,
id: 'videos-english',
name: WH.TERMS.englishvideos,
parent: 'lkljbjkb574',
tabs: tabsRelated,
tabOpts: {className: 'ugc-tab ugc-tab-videos'},
template: 'video',
});
}tabsRelated.flush();
//]]></script>
<h2 class="heading-size-2 wh-heading-responsive contribute-heading" data-text-style="heading-2-medium">Contribute</h2><div id="lkgljhkv456" class="comment-tabs"></div>
<div class="text comment-tabs-body individual-tab">
<div class="tabbed-contents" role="tabpanel" style="clear:none">
<div id="tab-add-your-comment" style="display:none">
Please keep the following in mind when posting a comment:<ul>
<li><div>Your comment must be in English or it will be removed.</div></li>
<li><div>Unsure how to post? Check out our <a href="/forever/help=commenting-and-you" target="_blank">handy guide</a>!</div></li>
<li><div>You might want to proof-read your comments before posting them.</div></li>
<li><div>Please post questions on our <a href="/forever/forums">forums</a> for quicker reply.</div></li>
</ul>
<div id="comment" class="add-comment-form">
<section class="comments-post-header">
<div class="imitation-heading heading-size-3 wh-heading-responsive contribute-heading-interior" data-text-style="heading-3">Post a Comment</div> </section>
<form>
<div class="comment-edit-body">
<textarea class="comment-editbox" rows="10" cols="40" name="commentbody" disabled="disabled"></textarea>
</div>
<small>You are not logged in. Please <a href="/forever/account=signin">log in</a> or <a href="/forever/auth/register">register an account</a> to add your comment.</small>
</form>
</div>
</div>
<div id="tab-submit-a-screenshot" style="display:none">
Simply browse for your screenshot using the form below. <ul>
<li><div>Screenshots containing UI elements are generally declined on sight, the same goes for screenshots from the modelviewer or character selection screen.</div></li>
<li><div>The higher the quality the better!</div></li>
<li><div>Please review our <a href="/forever/help=screenshot-guidelines" target="_blank">Screenshot Guidelines</a> before submitting!</div></li>
</ul>
<form action="" method="post">
<input type="file" name="screenshotfile" disabled="disabled"><br>
<small>You are not logged in. Please <a href="/forever/account=signin">log in</a> to submit a screenshot.</small>
</form>
</div>
<div id="tab-suggest-a-video" style="display:none">
Simply type the URL of the video in the form below. <div class="pad2"></div>
<form action="/forever/video=add&amp;2.415106" method="post" enctype="multipart/form-data"
onsubmit="return vi_validateForm(this)">
URL: <input type="text" name="videourl" style="width:35%">
<small>Supported: YouTube only</small>
<div class="pad2"></div>
Title: <input type="text" name="videotitle" maxlength="200"> <small>Optional, up to 200 characters</small><br>
<div class="pad"></div>
<input type="submit" value="Submit">
<div class="pad3"></div>
<small class="q0">Note: Your video will need to be approved before appearing on the site.</small>
</form>
</div>
<div id="tab-submit-your-data" style="display: none">
<p>The <b>Wowhead Client</b> is a little application we use to keep our database up to date, and to provide you with some nifty extra functionality on the website! &nbsp;</p>
<div class="pad2"></div>
<p>It serves 2 main purposes: &nbsp;</p>
<ol>
<li><div>It maintains a WoW addon called the <b>Wowhead Looter</b>, which collects data as you play the game! &nbsp;</div></li>
<li><div>It uploads the <b>collected data</b> to Wowhead in order to keep the database up-to-date! &nbsp;</div></li>
</ol>
<p>You can also use it to keep track of your completed quests, recipes, mounts, companion pets, and titles!&nbsp;</p>
<div class="pad2"></div>
<p>So, what are you waiting for? <a href="/forever/client">Download the client</a> and get started.&nbsp;</p>
</div>
</div>
</div>
<script>
var tabsContribute = new Tabs({parent: WH.ge('lkgljhkv456')});
tabsContribute.add(WH.TERMS.addyourcomment_stc, {id: 'add-your-comment'});tabsContribute.add(WH.TERMS.submitascreenshot_stc, {id: 'submit-a-screenshot'});tabsContribute.add(WH.TERMS.suggestavideo_stc, {id: 'suggest-a-video'});
tabsContribute.add(WH.TERMS.submityourdata_stc, {id: 'submit-your-data'});tabsContribute.flush();
</script></div> <div class="sidebar-wrapper" data-position="right">
<div class="rail-track">
<div class="zaf-block" id="zaf-right-rail-1" data-zaf-slot="true"></div>
</div>
<div class="rail-track">
<div class="zaf-block" id="zaf-right-rail-2" data-zaf-slot="true"></div>
</div>
</div>
</div></div> <div class="skin-wrapper" data-position="right"></div>
</div><footer class="footer" data-cls-hack="true"></footer></div> <script>
WH.Layout.Footer.init([[1,"All news posts","\/news\/rss\/all"],[2,"Classic series WoW news","\/news\/rss\/classic-series"],[3,"Retail WoW news","\/news\/rss\/retail"],[4,"In-development WoW news","\/news\/rss\/in-dev"],[5,"Other Blizzard games news","\/news\/rss\/other-blizzard-games"],[6,"Diablo news","\/news\/rss\/diablo"],[7,"Classic WoW news","\/news\/rss\/classic"],[26,"WoW Classic Era news","\/news\/rss\/classic-era"],[27,"WoW Classic Seasonal news","\/news\/rss\/classic-seasonal"],[8,"TBC WoW news","\/news\/rss\/burning-crusade-classic"],[22,"Wrath WoW news","\/news\/rss\/wotlk-classic"],[24,"Cataclysm WoW news","\/news\/rss\/cata-classic"],[25,"Mists WoW news","\/news\/rss\/mists-classic"],[9,"Diablo II: Resurrected news","\/news\/rss\/diablo-2"],[10,"Diablo Immortal news","\/news\/rss\/diablo-immortal"],[11,"Diablo IV news","\/news\/rss\/diablo-4"],[12,"All WoW news","\/news\/rss\/all-wow"],[13,"WoW PTR news","\/news\/rss\/wow-ptr"],[14,"WoW Beta news","\/news\/rss\/wow-beta"],[15,"WoW Live news","\/news\/rss\/wow-live"],[17,"Hearthstone news","\/news\/rss\/hearthstone"],[18,"Overwatch news","\/news\/rss\/overwatch"],[19,"Heroes of the Storm news","\/news\/rss\/heroes"],[20,"Warcraft III news","\/news\/rss\/wc3"],[21,"Starcraft news","\/news\/rss\/starcraft"],[23,"Warcraft Rumble news","\/news\/rss\/rumble"],[28,"Forever WoW news","\/news\/rss\/forever"]]);
</script>
<script>
qcdata = {} || qcdata;
(function(){
var elem = document.createElement('script');
elem.src = (document.location.protocol === "https:" ? "https://secure" : "http://pixel") +
".quantserve.com/aquant.js?a=p-80xBzMhbKolFI";
elem.async = true;
elem.type = "text/javascript";
var scpt = document.getElementsByTagName('script')[0];
scpt.parentNode.insertBefore(elem,scpt);
}());
var qcdata = {qacct: 'p-80xBzMhbKolFI', labels: ''};
</script>
<noscript>
<img src="//pixel.quantserve.com/pixel/p-80xBzMhbKolFI.gif?labels=_fp.event.Default" style="display: none;"
border="0" height="1" width="1" alt="Quantcast"/>
</noscript>
</body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,110 @@
from pathlib import Path
import pytest
from parsers import common, quest, object as object_parser
ROOT = Path(__file__).parent.parent
EXAMPLES = ROOT / "example_pages"
FIXTURES = Path(__file__).parent / "fixtures"
def read(path: Path) -> str:
if not path.exists():
pytest.skip(f"{path.name} not available")
return path.read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def quest_list():
return read(EXAMPLES / "https___www.wowhead.com_forever_quests#200.htm")
@pytest.fixture(scope="module")
def object_list():
return read(EXAMPLES / "https___www.wowhead.com_forever_objects.htm")
def test_quest_list_is_truncated_listview(quest_list):
rows, truncated, _ = common.list_page(quest_list, quest.LISTVIEW_ID)
assert len(rows) == 1000 and truncated
def test_quest_list_row(quest_list):
rows, _, _ = common.list_page(quest_list, quest.LISTVIEW_ID)
row = quest.list_row(next(r for r in rows if r["id"] == 96393), common.filter_categories(quest_list))
assert row == {
"id": 96393, "name": "Old Ironforge Incursion", "level": 16, "reqlevel": 9, "side": "Alliance",
"category": "The Hall of Thanes", "category_id": 16919, "category2": "Dungeons", "category2_id": 2,
"exp": 1450, "money": 800,
}
def test_category_ids_overlap_between_levels(quest_list):
categories = common.filter_categories(quest_list)
assert categories[1][0] == "Kalimdor"
assert categories[0][1][1] == "Dun Morogh" # zone 1 under Eastern Kingdoms
def test_object_list_uses_json_script(object_list):
rows, truncated, _ = common.list_page(object_list, object_parser.LISTVIEW_ID)
assert len(rows) == 1000 and truncated
row = object_parser.list_row(next(r for r in rows if r["id"] == 3689), common.filter_categories(object_list))
assert row == {"id": 3689, "name": "Weapon Crate", "type": "Containers", "type_id": 3, "skill": 0,
"zone_ids": "17;331"}
def test_quest_page_baron_marinous():
html = read(EXAMPLES / "https___www.wowhead.com_forever_quest=98028_baron-marinous.htm")
q = quest.parse_page(html, {"id": "98028", "category": "Darkshore"})
assert q["name"] == "Baron Marinous"
assert (q["level"], q["reqlevel"], q["side"]) == (21, 17, "Alliance")
assert q["objectives"] == "Bring the Clouded Water Globe to Onu at the Grove of the Ancients in Darkshore."
assert q["objective_items"] == [{"type": "item", "id": 279275, "name": "Clouded Water Globe",
"quantity": 1, "standing": None, "provided": True}]
assert q["description"].startswith("All that remains of the powerful elemental")
assert q["progress"].startswith("I can sense something far older")
assert "\n\nStairs descending down in Mathystra?" in q["completion"]
assert q["start"] == []
assert q["end"] == [{"type": "npc", "id": 3616, "name": "Onu"}]
assert q["locations"][0] == {"role": "end", "type": "npc", "id": 3616, "name": "Onu", "zone_id": 148,
"zone": "Darkshore", "coord": [43.6, 76.4], "coords": [[43.4, 76.2], [43.6, 76.4]]}
assert q["rewards"]["xp"] == 1650
assert q["rewards"]["money"] == 0
assert [i["name"] for i in q["rewards"]["item_choices"]] == ["Mathystra Channeling Orb", "Onu's Patience"]
assert q["rewards"]["reputation"] == [{"faction_id": 69, "faction": "Darnassus", "value": 150}]
assert "Not sharable" in q["quick_facts"]
def test_quest_page_with_giver_and_series():
q = quest.parse_page(read(FIXTURES / "quest_166.html"), {"id": "166"})
assert q["start"] == [{"type": "npc", "id": 234, "name": "Gryan Stoutmantle"}]
giver = next(loc for loc in q["locations"] if loc["role"] == "start")
assert (giver["name"], giver["zone"], giver["coord"]) == ("Gryan Stoutmantle", "Westfall", [56.2, 47.6])
assert q["series"] == [{"id": 155, "name": "The Defias Brotherhood"},
{"id": 166, "name": "The Defias Brotherhood"}]
def test_object_page_weapon_crate():
html = read(EXAMPLES / "https___www.wowhead.com_forever_object=3689_weapon-crate.htm")
o = object_parser.parse_page(html, {"id": "3689", "type": "Containers", "skill": "0"})
assert (o["name"], o["type"], o["type_id"], o["skill"]) == ("Weapon Crate", "Containers", 3, 0)
assert [(loc["zone"], loc["count"]) for loc in o["locations"]] == [("The Barrens", 16), ("Ashenvale", 7)]
assert len(o["locations"][0]["coords"]) == 16
bludgeon = next(i for i in o["contains"] if i["id"] == 1825)
assert bludgeon == {"id": 1825, "name": "Bulky Bludgeon", "count": 17, "percent": 0.4, "stack": [1, 1]}
def test_object_page_starts_and_ends_quests():
o = object_parser.parse_page(read(FIXTURES / "object_415106.html"), {"id": "415106"})
assert [q["id"] for q in o["starts"]] == [79007, 79192]
assert [q["id"] for q in o["ends"]] == [79008]
assert o["contains"] == []
def test_quest_page_with_reputation_objectives():
q = quest.parse_page(read(FIXTURES / "quest_1036.html"), {"id": "1036"})
assert [(o["type"], o["id"], o["name"], o["quantity"], o["standing"]) for o in q["objective_items"]] == [
("faction", 87, "Bloodsail Buccaneers", None, "Friendly"),
("faction", 21, "Booty Bay", None, "Hated"),
]

View File

@@ -0,0 +1,112 @@
import json
import re
import pytest
import config
from config import CrawlSettings, EntityConfig
from fetcher import NotFound
from spiders.base import Spider
class FakeFetcher:
"""Serves list pages for a fixed set of IDs the way Wowhead does: rows are capped per page,
and a filter bound above `filter_limit` is silently dropped."""
def __init__(self, ids, cap=4, filter_limit=1000):
self.ids, self.cap, self.filter_limit, self.urls = sorted(ids), cap, filter_limit, []
self.settings = CrawlSettings(concurrency=2)
def get(self, url):
self.urls.append(url)
m = re.search(r";(\d+):(\d+)$", url)
if m:
lo, hi = (b if b <= self.filter_limit else None for b in map(int, m.groups()))
rows = [{"id": i, "name": f"thing {i}"} for i in self.ids
if (lo is None or i >= lo) and (hi is None or i <= hi)]
rows.sort(key=lambda r: r["id"] % 7) # Wowhead sorts by popularity, not id
data = json.dumps(rows[:self.cap])
return f"new Listview({{template: 'thing', id: 'things', _truncated: {int(len(rows) > self.cap)}, data:{data}}});"
entity_id = int(url.rsplit("=", 1)[1])
if entity_id == 13:
raise NotFound(url)
return f"<h1>thing {entity_id}</h1>"
class ThingSpider(Spider):
entity = EntityConfig(name="thing", list_path="things", id_filter=1, max_id=100_000)
listview_id = "things"
csv_fields = ["id", "name", "url"]
def list_row(self, raw, categories):
return {"id": raw["id"], "name": raw["name"]}
def parse_page(self, html, row):
return {"id": int(row["id"]), "title": html[4:-5]}
@pytest.fixture(autouse=True)
def tmp_output(tmp_path, monkeypatch):
monkeypatch.setattr(config, "OUTPUT_DIR", tmp_path)
monkeypatch.setattr("spiders.base.OUTPUT_DIR", tmp_path)
return tmp_path
IDS = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987]
def test_list_bisects_until_nothing_is_truncated():
fetcher = FakeFetcher(IDS)
path = ThingSpider(fetcher).crawl_list()
lines = path.read_text(encoding="utf-8").splitlines()
assert lines[0] == "id,name,url"
assert [int(line.split(",")[0]) for line in lines[1:]] == IDS
assert lines[1].endswith("/forever/thing=1")
def test_list_skips_ranges_past_the_filter_limit():
fetcher = FakeFetcher(IDS)
ThingSpider(fetcher).crawl_list()
# Ranges starting above 1000 are answered with "id <= hi" (everything) and skipped, not split.
assert len(fetcher.urls) < 40
def test_list_page_total(tmp_output):
from parsers.common import list_page
html = "new Listview({id: 'x', note: \"5,442 quests found (1,000 displayed)\", _truncated: 1, data:[]});"
assert list_page(html, "x") == ([], True, 5442)
assert list_page("new Listview({id: 'x', data:[{\"id\": 1}]});", "x") == ([{"id": 1}], False, 1)
def test_details_resume_and_record_missing():
fetcher = FakeFetcher(IDS)
spider = ThingSpider(fetcher)
spider.crawl_list()
path = spider.crawl_details(limit=5)
assert len(path.read_text(encoding="utf-8").splitlines()) == 5
fetcher.urls.clear()
spider.crawl_details()
records = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
assert sorted(r["id"] for r in records) == IDS
assert len(fetcher.urls) == len(IDS) - 5 # only the remainder was fetched
assert next(r for r in records if r["id"] == 13)["not_found"] is True
assert next(r for r in records if r["id"] == 8)["title"] == "thing 8"
def test_find_missing_refetches_only_skipped_rows():
fetcher = FakeFetcher(IDS)
spider = ThingSpider(fetcher)
spider.crawl_list()
spider.crawl_details(limit=8) # IDs 1..34
path = spider.entity.detail_jsonl()
kept = [line for line in path.read_text(encoding="utf-8").splitlines()
if json.loads(line)["id"] not in (3, 13)] # pretend these were skipped while blocked
path.write_text("\n".join(kept) + "\n", encoding="utf-8")
assert spider.find_missing(dry_run=True) == [3, 13]
fetcher.urls.clear()
assert spider.find_missing() == [3, 13]
assert len(fetcher.urls) == 2 # IDs past 34 are left for `detail`
assert spider.find_missing() == []