initial commit
This commit is contained in:
328
quest_analyzer/experiments-1.ipynb
Normal file
328
quest_analyzer/experiments-1.ipynb
Normal 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
|
||||||
|
}
|
||||||
2070
quest_analyzer/experiments-2-ally.ipynb
Normal file
2070
quest_analyzer/experiments-2-ally.ipynb
Normal file
File diff suppressed because it is too large
Load Diff
2580
quest_analyzer/experiments-2.ipynb
Normal file
2580
quest_analyzer/experiments-2.ipynb
Normal file
File diff suppressed because it is too large
Load Diff
109
quest_analyzer/output/early_quests.csv
Normal file
109
quest_analyzer/output/early_quests.csv
Normal 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
|
||||||
|
109
quest_analyzer/output/quests.csv
Normal file
109
quest_analyzer/output/quests.csv
Normal 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,
|
||||||
|
107
quest_analyzer/output/travel-horde.csv
Normal file
107
quest_analyzer/output/travel-horde.csv
Normal 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
|
||||||
|
127
quest_analyzer/output/travel.csv
Normal file
127
quest_analyzer/output/travel.csv
Normal 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
|
||||||
|
3
quest_analyzer/requirements.txt
Normal file
3
quest_analyzer/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pandas
|
||||||
|
numpy
|
||||||
|
sklearn
|
||||||
BIN
quest_analyzer/resources/Durotar.jpg
Normal file
BIN
quest_analyzer/resources/Durotar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
BIN
quest_analyzer/resources/Mulgore.jpg
Normal file
BIN
quest_analyzer/resources/Mulgore.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
BIN
quest_analyzer/resources/Tirisfal_Glades.jpg
Normal file
BIN
quest_analyzer/resources/Tirisfal_Glades.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
BIN
quest_analyzer/resources/Zephras_Isle.jpg
Normal file
BIN
quest_analyzer/resources/Zephras_Isle.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
5443
quest_analyzer/resources/quests.csv
Normal file
5443
quest_analyzer/resources/quests.csv
Normal file
File diff suppressed because it is too large
Load Diff
5442
quest_analyzer/resources/quests.jsonl
Normal file
5442
quest_analyzer/resources/quests.jsonl
Normal file
File diff suppressed because one or more lines are too long
43
quest_analyzer/resources/scales.csv
Normal file
43
quest_analyzer/resources/scales.csv
Normal 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
|
||||||
|
8
wowhead_scraper/.gitignore
vendored
Normal file
8
wowhead_scraper/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
**/*.html
|
||||||
|
**/*.json
|
||||||
|
output/
|
||||||
|
.idea/
|
||||||
|
venv/
|
||||||
|
**/__pycache__
|
||||||
|
!tests/fixtures/*.html
|
||||||
|
.pytest_cache/
|
||||||
21
wowhead_scraper/LICENSE
Normal file
21
wowhead_scraper/LICENSE
Normal 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
71
wowhead_scraper/README.md
Normal 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
53
wowhead_scraper/config.py
Normal 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
121
wowhead_scraper/fetcher.py
Normal 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}")
|
||||||
0
wowhead_scraper/parsers/__init__.py
Normal file
0
wowhead_scraper/parsers/__init__.py
Normal file
142
wowhead_scraper/parsers/common.py
Normal file
142
wowhead_scraper/parsers/common.py
Normal 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(" ", " ").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
|
||||||
68
wowhead_scraper/parsers/object.py
Normal file
68
wowhead_scraper/parsers/object.py
Normal 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 []]
|
||||||
131
wowhead_scraper/parsers/quest.py
Normal file
131
wowhead_scraper/parsers/quest.py
Normal 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 []]
|
||||||
3
wowhead_scraper/pyproject.toml
Normal file
3
wowhead_scraper/pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
testpaths = ["tests"]
|
||||||
5
wowhead_scraper/requirements.txt
Normal file
5
wowhead_scraper/requirements.txt
Normal 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
41
wowhead_scraper/runner.py
Normal 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()
|
||||||
7
wowhead_scraper/spiders/__init__.py
Normal file
7
wowhead_scraper/spiders/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from .object import ObjectSpider
|
||||||
|
from .quest import QuestSpider
|
||||||
|
|
||||||
|
SPIDERS = {
|
||||||
|
"quest": QuestSpider,
|
||||||
|
"object": ObjectSpider,
|
||||||
|
}
|
||||||
153
wowhead_scraper/spiders/base.py
Normal file
153
wowhead_scraper/spiders/base.py
Normal 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)
|
||||||
15
wowhead_scraper/spiders/object.py
Normal file
15
wowhead_scraper/spiders/object.py
Normal 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)
|
||||||
15
wowhead_scraper/spiders/quest.py
Normal file
15
wowhead_scraper/spiders/quest.py
Normal 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)
|
||||||
538
wowhead_scraper/tests/fixtures/object_415106.html
vendored
Normal file
538
wowhead_scraper/tests/fixtures/object_415106.html
vendored
Normal 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("css", 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("js");"></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("anon-js");"></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&db=1792500090&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> & 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({"type":2,"typeId":415106,"displayId":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, {"type":2,"typeId":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>
|
||||||
|
(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&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! </p>
|
||||||
|
<div class="pad2"></div>
|
||||||
|
<p>It serves 2 main purposes: </p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li><div>It maintains a WoW addon called the <b>Wowhead Looter</b>, which collects data as you play the game! </div></li>
|
||||||
|
<li><div>It uploads the <b>collected data</b> to Wowhead in order to keep the database up-to-date! </div></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p>You can also use it to keep track of your completed quests, recipes, mounts, companion pets, and titles! </p>
|
||||||
|
<div class="pad2"></div>
|
||||||
|
<p>So, what are you waiting for? <a href="/forever/client">Download the client</a> and get started. </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>
|
||||||
730
wowhead_scraper/tests/fixtures/quest_1036.html
vendored
Normal file
730
wowhead_scraper/tests/fixtures/quest_1036.html
vendored
Normal file
File diff suppressed because one or more lines are too long
743
wowhead_scraper/tests/fixtures/quest_166.html
vendored
Normal file
743
wowhead_scraper/tests/fixtures/quest_166.html
vendored
Normal file
File diff suppressed because one or more lines are too long
110
wowhead_scraper/tests/test_parsers.py
Normal file
110
wowhead_scraper/tests/test_parsers.py
Normal 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"),
|
||||||
|
]
|
||||||
112
wowhead_scraper/tests/test_spider.py
Normal file
112
wowhead_scraper/tests/test_spider.py
Normal 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() == []
|
||||||
Reference in New Issue
Block a user